//! Schema snapshot capture + the pure diff engine (engine lands in Task 14). //! //! Snapshots are read-only, single-use, and capped (5,000 objects — spec §2). //! Sync SQL is generated in the TARGET's dialect with identifiers quoted //! per engine (spec §2: never raw interpolation). use crate::db::pool::DbHandle; use crate::models::db_viewer::*; use std::collections::BTreeMap; pub(crate) const SNAPSHOT_CAP: usize = 5_000; /// Map a PG `conf*type` action code to its SQL name. pub(crate) fn pg_fk_action(code: &str) -> &'static str { match code { "r" => "RESTRICT", "c" => "CASCADE", "n" => "SET NULL", "d" => "SET DEFAULT", _ => "NO ACTION", } } /// Enforce the 5,000-object cap, setting `truncated` (never silent). pub(crate) fn apply_snapshot_cap(snap: &mut SchemaSnapshot) { let mut total = snap.tables.len() + snap.views.len() + snap.sequences.len() + snap.enums.len(); let over = total > SNAPSHOT_CAP; if over { let mut remaining = SNAPSHOT_CAP; fn take(v: &mut Vec, budget: &mut usize) { if v.len() > *budget { v.truncate(*budget); } *budget -= v.len(); } take(&mut snap.tables, &mut remaining); take(&mut snap.views, &mut remaining); take(&mut snap.sequences, &mut remaining); take(&mut snap.enums, &mut remaining); total = SNAPSHOT_CAP; } snap.object_count = total; snap.truncated = over; } fn csv_names(s: &str) -> Vec { s.split(", ").map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect() } /// Capture a PostgreSQL schema snapshot (six set-based queries, one per class). pub(crate) async fn capture_pg( client: &tokio_postgres::Client, engine: &str, schema: &str, ) -> Result { let mut tables: BTreeMap = BTreeMap::new(); let mut views: Vec = Vec::new(); let mut sequences: Vec = Vec::new(); let mut enums: BTreeMap = BTreeMap::new(); // Columns let rows = client .query(&crate::db::introspection::pg_diff_columns_query(), &[&schema]) .await .map_err(|e| format!("columns query failed: {e}"))?; for r in rows { let tname: String = r.get(0); let entry = tables.entry(tname.clone()).or_insert_with(|| SnapshotTable { schema: schema.to_string(), name: tname, ..Default::default() }); entry.columns.push(SnapshotColumn { name: r.get(1), data_type: r.get(2), nullable: r.get(3), default_value: r.get::<_, Option>(4), }); } // Constraints (PK / UNIQUE / CHECK / FK) // Column order: table_name(0), constraint_name(1), contype(2), cols(3), // ref_cols(4), ref_table(5), confdeltype(6), confupdtype(7), definition(8) let rows = client .query(&crate::db::introspection::pg_diff_constraints_query(), &[&schema]) .await .map_err(|e| format!("constraints query failed: {e}"))?; for r in rows { let tname: String = r.get(0); let cname: String = r.get(1); let contype: String = r.get(2); let Some(entry) = tables.get_mut(&tname) else { continue }; match contype.as_str() { "p" => entry.primary_key = csv_names(&r.get::<_, Option>(3).unwrap_or_default()), "u" => entry.uniques.push(SnapshotConstraint { name: cname, kind: "unique".into(), definition: r.get::<_, Option>(3).unwrap_or_default(), }), "c" => entry.checks.push(SnapshotConstraint { name: cname, kind: "check".into(), definition: r.get::<_, Option>(8).unwrap_or_default(), }), "f" => { let ref_qualified: Option = r.get(5); let (ref_schema, ref_table) = ref_qualified .map(|q| match q.split_once('.') { Some((s, t)) => (s.to_string(), t.to_string()), None => (String::new(), q.clone()), }) .unwrap_or_default(); entry.foreign_keys.push(SnapshotFk { name: cname, columns: csv_names(&r.get::<_, Option>(3).unwrap_or_default()), ref_schema, ref_table, ref_columns: csv_names(&r.get::<_, Option>(4).unwrap_or_default()), on_delete: pg_fk_action(&r.get::<_, Option>(6).unwrap_or_default()).to_string(), on_update: pg_fk_action(&r.get::<_, Option>(7).unwrap_or_default()).to_string(), }); } _ => {} } } // Indexes let rows = client .query(&crate::db::introspection::pg_diff_indexes_query(), &[&schema]) .await .map_err(|e| format!("indexes query failed: {e}"))?; for r in rows { let tname: String = r.get(0); let Some(entry) = tables.get_mut(&tname) else { continue }; entry.indexes.push(SnapshotIndex { name: r.get(1), columns: csv_names(&r.get::<_, Option>(3).unwrap_or_default()), unique: r.get(2), }); } // Views + matviews let rows = client .query(&crate::db::introspection::pg_diff_views_query(), &[&schema]) .await .map_err(|e| format!("views query failed: {e}"))?; for r in rows { views.push(SnapshotView { schema: r.get(0), name: r.get(1), definition: r.get(2), materialized: r.get(3), }); } // Sequences let rows = client .query(&crate::db::introspection::pg_diff_sequences_query(), &[&schema]) .await .map_err(|e| format!("sequences query failed: {e}"))?; for r in rows { sequences.push(SnapshotSequence { schema: r.get(0), name: r.get(1), start: r.get(2), increment: r.get(3), minimum: r.get(4), maximum: r.get(5), cycle: r.get(6), }); } // Enums (one row per label — aggregate by name) let rows = client .query(&crate::db::introspection::pg_diff_enums_query(), &[&schema]) .await .map_err(|e| format!("enums query failed: {e}"))?; for r in rows { let ename: String = r.get(1); let entry = enums.entry(ename.clone()).or_insert_with(|| SnapshotEnum { schema: r.get(0), name: ename, labels: Vec::new(), }); entry.labels.push(r.get(2)); } let mut snap = SchemaSnapshot { engine: engine.to_string(), schema: schema.to_string(), tables: tables.into_values().collect(), views, sequences, enums: enums.into_values().collect(), object_count: 0, truncated: false, }; apply_snapshot_cap(&mut snap); Ok(snap) } /// Capture a SQLite (in-file/in-memory) snapshot. pub(crate) fn capture_sqlite(conn: &rusqlite::Connection) -> Result { crate::db::schema_diff::capture::sqlite(conn) } /// Capture a MySQL/MariaDB snapshot. pub(crate) async fn capture_mysql( conn: &mut sqlx::mysql::MySqlConnection, engine: &str, schema: &str, ) -> Result { crate::db::schema_diff::capture::mysql(conn, engine, schema).await } /// Dispatch a snapshot capture across engines. `engine` is the CONNECTION's /// db_type (mariadb vs mysql matters for the diff dialect). pub(crate) async fn capture_snapshot( handle: &mut DbHandle, engine: &str, schema: &str, ) -> Result { match handle { DbHandle::Postgresql(client, _) => capture_pg(client, engine, schema).await, DbHandle::MySql(pool) => { let mut conn = pool .acquire() .await .map_err(|e| format!("source/target connection failed: {e}"))?; capture_mysql(&mut *conn, engine, schema).await } DbHandle::Sqlite(conn) => capture_sqlite(conn), } } pub(crate) mod capture { use super::*; pub(crate) fn sqlite(conn: &rusqlite::Connection) -> Result { let mut snap = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() }; let mut stmt = conn .prepare("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND type IN ('table', 'view') ORDER BY name") .map_err(|e| e.to_string())?; let objects: Vec<(String, String, Option)> = stmt .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); for (otype, name, sql) in objects { if otype == "view" { snap.views.push(SnapshotView { schema: "main".into(), name, definition: sql.unwrap_or_default(), materialized: false, }); continue; } let mut table = SnapshotTable { schema: "main".into(), name: name.clone(), ..Default::default() }; // Columns + PK + defaults let mut cols = conn .prepare(&format!("PRAGMA table_info({})", quote_double(&name))) .map_err(|e| e.to_string())?; let col_rows: Vec<(String, String, i64, Option, i64)> = cols .query_map([], |r| Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); drop(cols); for (cname, ctype, notnull, default, pk) in col_rows { if pk > 0 { table.primary_key.push(cname.clone()); } table.columns.push(SnapshotColumn { name: cname, data_type: ctype, nullable: notnull == 0, default_value: default, }); } // Indexes (skip the PK-mandated sqlite_autoindex_*) let mut idx = conn .prepare(&format!("PRAGMA index_list({})", quote_double(&name))) .map_err(|e| e.to_string())?; let idx_rows: Vec<(String, i64)> = idx .query_map([], |r| Ok((r.get(1)?, r.get(2)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); drop(idx); for (iname, unique) in idx_rows { if iname.starts_with("sqlite_autoindex") { continue; } let mut info = conn .prepare(&format!("PRAGMA index_info({})", quote_double(&iname))) .map_err(|e| e.to_string())?; let icols: Vec = info .query_map([], |r| r.get::<_, String>(2)) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); drop(info); table.indexes.push(SnapshotIndex { name: iname, columns: icols, unique: unique != 0, }); } // Foreign keys let mut fks = conn .prepare(&format!("PRAGMA foreign_key_list({})", quote_double(&name))) .map_err(|e| e.to_string())?; let fk_rows: Vec<(i64, i64, String, String, String, String, String)> = fks .query_map([], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); drop(fks); // (id, seq, table, from, to, on_update, on_delete) — group by id let mut grouped: BTreeMap = BTreeMap::new(); for (id, _seq, reftable, from, to, on_update, on_delete) in fk_rows { let fk = grouped.entry(id).or_insert_with(|| SnapshotFk { name: format!("fk_{name}_{id}"), columns: Vec::new(), ref_schema: "main".into(), ref_table: reftable, ref_columns: Vec::new(), on_delete: String::new(), on_update: String::new(), }); fk.columns.push(from); if !to.is_empty() { fk.ref_columns.push(to); } if !on_delete.is_empty() { fk.on_delete = on_delete.to_uppercase(); } if !on_update.is_empty() { fk.on_update = on_update.to_uppercase(); } } table.foreign_keys = grouped.into_values().collect(); snap.tables.push(table); } apply_snapshot_cap(&mut snap); Ok(snap) } pub(crate) async fn mysql( conn: &mut sqlx::mysql::MySqlConnection, engine: &str, schema: &str, ) -> Result { use crate::db::mysql::{mysql_row_string, mysql_snapshot_columns_query, mysql_snapshot_constraints_query, mysql_snapshot_indexes_query, mysql_snapshot_views_query}; let mut tables: BTreeMap = BTreeMap::new(); let mut views: Vec = Vec::new(); let rows = sqlx::query(&mysql_snapshot_columns_query(schema)) .fetch_all(&mut *conn) .await .map_err(|e| format!("columns query failed: {e}"))?; for r in rows { let tname = mysql_row_string(&r, 0); let entry = tables.entry(tname.clone()).or_insert_with(|| SnapshotTable { schema: schema.to_string(), name: tname, ..Default::default() }); entry.columns.push(SnapshotColumn { name: mysql_row_string(&r, 1), data_type: mysql_row_string(&r, 2), nullable: mysql_row_string(&r, 3) == "YES", default_value: (!mysql_row_string(&r, 4).is_empty()).then(|| mysql_row_string(&r, 4)), }); } let rows = sqlx::query(&mysql_snapshot_constraints_query(schema)) .fetch_all(&mut *conn) .await .map_err(|e| format!("constraints query failed: {e}"))?; for r in rows { let tname = mysql_row_string(&r, 0); let cname = mysql_row_string(&r, 1); let ctype = mysql_row_string(&r, 2); let col = mysql_row_string(&r, 3); let Some(entry) = tables.get_mut(&tname) else { continue }; match ctype.as_str() { "PRIMARY KEY" => { if !entry.primary_key.contains(&col) { entry.primary_key.push(col); } } "UNIQUE" => { if let Some(u) = entry.uniques.iter_mut().find(|u| u.name == cname) { u.definition = if u.definition.is_empty() { col.clone() } else { format!("{}, {col}", u.definition) }; } else { entry.uniques.push(SnapshotConstraint { name: cname, kind: "unique".into(), definition: col, }); } } "FOREIGN KEY" => { let ref_table = mysql_row_string(&r, 4); let on_delete = mysql_row_string(&r, 5).to_uppercase(); let on_update = mysql_row_string(&r, 6).to_uppercase(); if let Some(fk) = entry.foreign_keys.iter_mut().find(|f| f.name == cname) { fk.columns.push(col); } else { entry.foreign_keys.push(SnapshotFk { name: cname, columns: vec![col], ref_schema: String::new(), ref_table, ref_columns: Vec::new(), on_delete, on_update, }); } } "CHECK" => { if let Some(c) = entry.checks.iter_mut().find(|c| c.name == cname) { c.definition = format!("{} {}", c.definition, mysql_row_string(&r, 7)); } else { entry.checks.push(SnapshotConstraint { name: cname, kind: "check".into(), definition: mysql_row_string(&r, 7), }); } } _ => {} } } let rows = sqlx::query(&mysql_snapshot_indexes_query(schema)) .fetch_all(&mut *conn) .await .map_err(|e| format!("indexes query failed: {e}"))?; for r in rows { let tname = mysql_row_string(&r, 0); let iname = mysql_row_string(&r, 1); let unique = mysql_row_string(&r, 2) == "0"; let col = mysql_row_string(&r, 3); let Some(entry) = tables.get_mut(&tname) else { continue }; if let Some(ix) = entry.indexes.iter_mut().find(|i| i.name == iname) { ix.columns.push(col); } else { entry.indexes.push(SnapshotIndex { name: iname, columns: vec![col], unique, }); } } let rows = sqlx::query(&mysql_snapshot_views_query(schema)) .fetch_all(&mut *conn) .await .map_err(|e| format!("views query failed: {e}"))?; for r in rows { views.push(SnapshotView { schema: mysql_row_string(&r, 0), name: mysql_row_string(&r, 1), definition: mysql_row_string(&r, 2), materialized: false, }); } let mut snap = SchemaSnapshot { engine: engine.to_string(), schema: schema.to_string(), tables: tables.into_values().collect(), views, sequences: Vec::new(), enums: Vec::new(), object_count: 0, truncated: false, }; apply_snapshot_cap(&mut snap); Ok(snap) } } /// Double-quote an identifier (PG/SQLite) with embedded-quote doubling. pub(crate) fn quote_double(name: &str) -> String { format!("\"{}\"", name.replace('"', "\"\"")) } // --------------------------------------------------------------------------- // Pure diff engine (spec §4/§5A) // --------------------------------------------------------------------------- // Destructive classification (binding): DROP TABLE/VIEW/INDEX/SEQUENCE/TYPE/ // CONSTRAINT, DROP COLUMN, and ALTER COLUMN TYPE are destructive (copy-only); // everything else (CREATE, ADD COLUMN, ADD CONSTRAINT, RENAME, SET/DROP // DEFAULT, SET/DROP NOT NULL) is stageable. pub(crate) fn engine_family(engine: &str) -> &'static str { match engine { "mysql" | "mariadb" => "mysql", "sqlite" => "sqlite", _ => "postgresql", } } fn quote(family: &str, name: &str) -> String { match family { "mysql" => format!("`{}`", name.replace('`', "``")), _ => format!("\"{}\"", name.replace('"', "\"\"")), } } fn qualified(family: &str, schema: &str, name: &str) -> String { match family { "mysql" => format!("`{}`.`{}`", schema.replace('`', "``"), name.replace('`', "``")), "sqlite" => quote(family, name), _ => format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), name.replace('"', "\"\"")), } } fn detail_line(label: &str, old: &str, new: &str) -> DiffDetailLine { DiffDetailLine { label: label.into(), old: old.into(), new: new.into() } } fn item(ty: &str, name: &str, kind: &str, detail: Vec, sql: Vec, destructive: bool) -> DiffItem { DiffItem { object_type: ty.into(), name: name.into(), kind: kind.into(), detail, sync_sql: sql, destructive, } } /// Diff two snapshots into a report. Sync SQL is in the TARGET's dialect. pub fn diff_snapshots(source: &SchemaSnapshot, target: &SchemaSnapshot) -> DiffReport { let family = engine_family(&target.engine); let schema = &target.schema; let mut items: Vec = Vec::new(); // ── Enums (PG) — must come first (tables may depend on them) ────────── for (s_enum, t_enum) in enum_pairs(&source.enums, &target.enums) { match (s_enum, t_enum) { (Some(s), None) => items.push(item( "enum", &s.name, "added", vec![], vec![format!( "CREATE TYPE {}.{} AS ENUM ({});", quote(family, &s.schema), quote(family, &s.name), s.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect::>().join(", ") )], false, )), (None, Some(t)) => items.push(item( "enum", &t.name, "removed", vec![], vec![format!("DROP TYPE {}.{};", quote(family, &t.schema), quote(family, &t.name))], true, )), (Some(s), Some(t)) => { let mut added = Vec::new(); let mut removed_or_reordered = false; for l in &s.labels { if !t.labels.contains(l) { added.push(l.clone()); } } if t.labels.iter().any(|l| !s.labels.contains(l)) || !common_prefix(&s.labels, &t.labels) { removed_or_reordered = true; } if !added.is_empty() { if removed_or_reordered { items.push(item("enum", &s.name, "changed", vec![detail_line("labels", &t.labels.join(", "), &s.labels.join(", "))], vec![], true)); } else { items.push(item( "enum", &s.name, "changed", vec![detail_line("added labels", "", &added.join(", "))], added.iter().map(|l| format!("ALTER TYPE {}.{} ADD VALUE '{}';", quote(family, &s.schema), quote(family, &s.name), l.replace('\'', "''"))).collect(), false, )); } } } (None, None) => {} } } // ── Sequences (PG) ───────────────────────────────────────────────────── for (s_seq, t_seq) in pair_by(&source.sequences, &target.sequences, |s| s.name.clone()) { match (s_seq, t_seq) { (Some(s), None) => items.push(item( "sequence", &s.name, "added", vec![], vec![format!( "CREATE SEQUENCE {q} START WITH {start} INCREMENT BY {inc} MINVALUE {min} MAXVALUE {max} {cycle};", q = qualified(family, schema, &s.name), start = s.start, inc = s.increment, min = s.minimum, max = s.maximum, cycle = if s.cycle { "CYCLE" } else { "NO CYCLE" } )], false, )), (None, Some(t)) => items.push(item( "sequence", &t.name, "removed", vec![], vec![format!("DROP SEQUENCE {};", qualified(family, schema, &t.name))], true, )), (Some(s), Some(t)) => { let mut sql = String::new(); let mut detail = Vec::new(); for (label, sv, tv) in [ ("start", &s.start, &t.start), ("increment", &s.increment, &t.increment), ("minimum", &s.minimum, &t.minimum), ("maximum", &s.maximum, &t.maximum), ] { if sv != tv { detail.push(detail_line(label, tv, sv)); if !sql.is_empty() { sql.push(' '); } match label { "start" => sql.push_str(&format!("START WITH {sv}")), "increment" => sql.push_str(&format!("INCREMENT BY {sv}")), "minimum" => sql.push_str(&format!("MINVALUE {sv}")), "maximum" => sql.push_str(&format!("MAXVALUE {sv}")), _ => {} } } } if s.cycle != t.cycle { detail.push(detail_line("cycle", &t.cycle.to_string(), &s.cycle.to_string())); if !sql.is_empty() { sql.push(' '); } sql.push_str(if s.cycle { "CYCLE" } else { "NO CYCLE" }); } if !sql.is_empty() { items.push(item("sequence", &s.name, "changed", detail, vec![format!("ALTER SEQUENCE {} {};", qualified(family, schema, &s.name), sql)], false)); } } (None, None) => {} } } // ── Tables ───────────────────────────────────────────────────────────── for (s_t, t_t) in pair_by(&source.tables, &target.tables, |t| t.name.clone()) { match (s_t, t_t) { (Some(s), None) => { let mut defs: Vec = s.columns.iter().map(|c| { let mut d = format!("{} {}", quote(family, &c.name), c.data_type); if !c.nullable { d.push_str(" NOT NULL"); } if let Some(def) = &c.default_value { d.push_str(&format!(" DEFAULT {def}")); } d }).collect(); if !s.primary_key.is_empty() { defs.push(format!("PRIMARY KEY ({})", s.primary_key.iter().map(|p| quote(family, p)).collect::>().join(", "))); } items.push(item( "table", &s.name, "added", vec![], vec![format!("CREATE TABLE {} ({});", qualified(family, schema, &s.name), defs.join(", "))], false, )); push_constraint_items(&mut items, family, schema, s, "added"); } (None, Some(t)) => items.push(item( "table", &t.name, "removed", vec![], vec![format!("DROP TABLE {};", qualified(family, schema, &t.name))], true, )), (Some(s), Some(t)) => diff_table(&mut items, family, schema, s, t), (None, None) => {} } } // ── Views ────────────────────────────────────────────────────────────── for (s_v, t_v) in pair_by(&source.views, &target.views, |v| v.name.clone()) { match (s_v, t_v) { (Some(s), None) => items.push(item( "view", &s.name, "added", vec![], vec![format!("CREATE {}VIEW {} AS {};", if s.materialized { "MATERIALIZED " } else { "" }, qualified(family, schema, &s.name), s.definition.trim_end_matches(';'))], false, )), (None, Some(t)) => items.push(item( "view", &t.name, "removed", vec![], vec![format!("DROP {}VIEW {};", if t.materialized { "MATERIALIZED " } else { "" }, qualified(family, schema, &t.name))], true, )), (Some(s), Some(t)) if s.definition.trim() != t.definition.trim() => { if family == "postgresql" || family == "mysql" { if s.materialized || t.materialized { items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)], vec![format!("DROP MATERIALIZED VIEW {q}; CREATE MATERIALIZED VIEW {q} AS {d};", q = qualified(family, schema, &s.name), d = s.definition.trim_end_matches(';'))], true)); } else { items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)], vec![format!("CREATE OR REPLACE VIEW {} AS {};", qualified(family, schema, &s.name), s.definition.trim_end_matches(';'))], false)); } } else { items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)], vec![], true)); } } _ => {} } } DiffReport { items, source_label: format!("{} ({})", source.schema, source.engine), target_label: format!("{} ({})", target.schema, target.engine), truncated: source.truncated || target.truncated, } } /// Items safe to stage: non-destructive AND carrying SQL. pub fn stageable_items(report: &DiffReport) -> Vec<&DiffItem> { report.items.iter().filter(|i| !i.destructive && !i.sync_sql.is_empty()).collect() } fn common_prefix(source: &[String], target: &[String]) -> bool { let mut ti = 0usize; for l in target { if source.contains(l) { if ti >= source.len() || &source[ti] != l { return false; } ti += 1; } } true } /// Pair two keyed lists into (source, target) options by key. fn pair_by<'a, T, K: Ord + Clone + Eq>(src: &'a [T], tgt: &'a [T], key: impl Fn(&T) -> K) -> Vec<(Option<&'a T>, Option<&'a T>)> { use std::collections::BTreeMap; let mut map: BTreeMap, Option<&T>)> = BTreeMap::new(); for s in src { map.entry(key(s)).or_insert((None, None)).0 = Some(s); } for t in tgt { map.entry(key(t)).or_insert((None, None)).1 = Some(t); } map.into_values().collect() } fn enum_pairs<'a>(src: &'a [SnapshotEnum], tgt: &'a [SnapshotEnum]) -> Vec<(Option<&'a SnapshotEnum>, Option<&'a SnapshotEnum>)> { pair_by(src, tgt, |e| e.name.clone()) } fn push_constraint_items(items: &mut Vec, family: &str, schema: &str, table: &SnapshotTable, kind: &str) { let qtn = qualified(family, schema, &table.name); for fk in &table.foreign_keys { items.push(item( "constraint", &format!("{}.{}", table.name, fk.name), kind, vec![], vec![constraint_fk_sql(family, schema, &qtn, fk)], false, )); } for u in &table.uniques { let cols = u.definition.split(", ").map(|c| quote(family, c.trim())).collect::>().join(", "); items.push(item( "constraint", &format!("{}.{}", table.name, u.name), kind, vec![], vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &u.name), cols = cols)], false, )); } for c in &table.checks { items.push(item( "constraint", &format!("{}.{}", table.name, c.name), kind, vec![], vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &c.name), d = c.definition.trim())], false, )); } for ix in &table.indexes { let cols = ix.columns.iter().map(|c| quote(family, c)).collect::>().join(", "); items.push(item( "index", &format!("{}.{}", table.name, ix.name), kind, vec![], vec![format!("CREATE {u}INDEX {n} ON {qtn} ({cols});", u = if ix.unique { "UNIQUE " } else { "" }, n = quote(family, &ix.name), cols = cols)], false, )); } } fn constraint_fk_sql(family: &str, _schema: &str, qtn: &str, fk: &SnapshotFk) -> String { let cols = fk.columns.iter().map(|c| quote(family, c)).collect::>().join(", "); let ref_cols = fk.ref_columns.iter().map(|c| quote(family, c)).collect::>().join(", "); let ref_qualified = match family { "mysql" => format!("`{}`", fk.ref_table.replace('`', "``")), "sqlite" => quote(family, &fk.ref_table), _ => qualified(family, &fk.ref_schema, &fk.ref_table), }; format!( "ALTER TABLE {qtn} ADD CONSTRAINT {n} FOREIGN KEY ({cols}) REFERENCES {r} ({rc}) ON DELETE {od} ON UPDATE {ou};", n = quote(family, &fk.name), cols = cols, r = ref_qualified, rc = ref_cols, od = fk.on_delete, ou = fk.on_update ) } fn diff_table(items: &mut Vec, family: &str, schema: &str, s: &SnapshotTable, t: &SnapshotTable) { let qtn = qualified(family, schema, &s.name); for (s_c, t_c) in pair_by(&s.columns, &t.columns, |c| c.name.clone()) { match (s_c, t_c) { (Some(c), None) => { let mut sql = format!("ALTER TABLE {qtn} ADD COLUMN {n} {ty}", n = quote(family, &c.name), ty = c.data_type); if !c.nullable { if family == "sqlite" && c.default_value.is_none() { items.push(item("column", &format!("{}.{}", s.name, c.name), "added", vec![detail_line("nullable", "false", "true (SQLite cannot ADD COLUMN NOT NULL without DEFAULT)")], vec![format!("{sql};")], false)); continue; } sql.push_str(" NOT NULL"); } if let Some(d) = &c.default_value { sql.push_str(&format!(" DEFAULT {d}")); } items.push(item("column", &format!("{}.{}", s.name, c.name), "added", vec![], vec![format!("{sql};")], false)); } (None, Some(c)) => items.push(item( "column", &format!("{}.{}", s.name, c.name), "removed", vec![], vec![format!("ALTER TABLE {qtn} DROP COLUMN {n};", n = quote(family, &c.name))], true, )), (Some(sc), Some(tc)) => { if sc.data_type != tc.data_type { let sql = match family { "postgresql" => format!("ALTER TABLE {qtn} ALTER COLUMN {n} TYPE {ty} USING {n}::{ty};", n = quote(family, &sc.name), ty = sc.data_type), "mysql" => format!("ALTER TABLE {qtn} MODIFY COLUMN {n} {ty}{nn};", n = quote(family, &sc.name), ty = sc.data_type, nn = if sc.nullable { String::new() } else { " NOT NULL".into() }), _ => String::new(), }; items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed", vec![detail_line("type", &tc.data_type, &sc.data_type)], if sql.is_empty() { vec![] } else { vec![sql] }, true)); } if sc.nullable != tc.nullable { let sql = match family { "postgresql" => format!("ALTER TABLE {qtn} ALTER COLUMN {n} {op};", n = quote(family, &sc.name), op = if sc.nullable { "DROP NOT NULL" } else { "SET NOT NULL" }), "mysql" => format!("ALTER TABLE {qtn} MODIFY COLUMN {n} {ty}{nn};", n = quote(family, &sc.name), ty = sc.data_type, nn = if sc.nullable { String::new() } else { " NOT NULL".into() }), _ => String::new(), }; if !sql.is_empty() { items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed", vec![detail_line("nullable", &tc.nullable.to_string(), &sc.nullable.to_string())], vec![sql], false)); } } if sc.default_value != tc.default_value { let sql = match family { "postgresql" => match &sc.default_value { Some(d) => format!("ALTER TABLE {qtn} ALTER COLUMN {n} SET DEFAULT {d};", n = quote(family, &sc.name)), None => format!("ALTER TABLE {qtn} ALTER COLUMN {n} DROP DEFAULT;", n = quote(family, &sc.name)), }, "mysql" => match &sc.default_value { Some(d) => format!("ALTER TABLE {qtn} ALTER COLUMN {n} SET DEFAULT {d};", n = quote(family, &sc.name)), None => format!("ALTER TABLE {qtn} ALTER COLUMN {n} DROP DEFAULT;", n = quote(family, &sc.name)), }, _ => String::new(), }; if !sql.is_empty() { items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed", vec![detail_line("default", &tc.default_value.as_deref().unwrap_or_default(), &sc.default_value.clone().unwrap_or_default())], vec![sql], false)); } } } (None, None) => {} } } if s.primary_key != t.primary_key { items.push(item("table", &s.name, "changed", vec![detail_line("primary key", &t.primary_key.join(", "), &s.primary_key.join(", "))], vec![], true)); } for (s_c, t_c) in pair_by(&s.uniques, &t.uniques, |c| c.name.clone()) { push_constraint_delta(items, family, s, "UNIQUE", s_c, t_c); } for (s_c, t_c) in pair_by(&s.checks, &t.checks, |c| c.name.clone()) { push_constraint_delta(items, family, s, "CHECK", s_c, t_c); } for (s_f, t_f) in pair_by(&s.foreign_keys, &t.foreign_keys, |f| f.name.clone()) { let qtn2 = qualified(family, schema, &s.name); match (s_f, t_f) { (Some(f), None) => { if family == "sqlite" { items.push(item("constraint", &format!("{}.{}", s.name, f.name), "added", vec![detail_line("note", "", "SQLite cannot add foreign keys to existing tables — table rebuild required")], vec![], false)); } else { items.push(item("constraint", &format!("{}.{}", s.name, f.name), "added", vec![], vec![constraint_fk_sql(family, schema, &qtn2, f)], false)); } } (None, Some(f)) => items.push(item( "constraint", &format!("{}.{}", s.name, f.name), "removed", vec![], vec![format!("ALTER TABLE {qtn2} DROP CONSTRAINT {n};", n = quote(family, &f.name))], true, )), (Some(sf), Some(tf)) if sf.definition_differs(tf) => { items.push(item("constraint", &format!("{}.{}", s.name, sf.name), "changed", vec![detail_line("definition", &tf.on_delete, &sf.on_delete)], vec![format!("ALTER TABLE {qtn2} DROP CONSTRAINT {n}; ALTER TABLE {qtn2} ADD CONSTRAINT {n2} FOREIGN KEY ({cols}) REFERENCES {r} ({rc}) ON DELETE {od} ON UPDATE {ou};", n = quote(family, &sf.name), n2 = quote(family, &sf.name), cols = sf.columns.iter().map(|c| quote(family, c)).collect::>().join(", "), r = qualified(family, &sf.ref_schema, &sf.ref_table), rc = sf.ref_columns.iter().map(|c| quote(family, c)).collect::>().join(", "), od = sf.on_delete, ou = sf.on_update)], true)); } _ => {} } } for (s_i, t_i) in pair_by(&s.indexes, &t.indexes, |i| i.name.clone()) { match (s_i, t_i) { (Some(ix), None) => items.push(item( "index", &format!("{}.{}", s.name, ix.name), "added", vec![], vec![format!("CREATE {u}INDEX {n} ON {qtn} ({cols});", u = if ix.unique { "UNIQUE " } else { "" }, n = qualified(family, schema, &ix.name), cols = ix.columns.iter().map(|c| quote(family, c)).collect::>().join(", "))], false, )), (None, Some(ix)) => items.push(item( "index", &format!("{}.{}", s.name, ix.name), "removed", vec![], vec![format!("DROP INDEX {n};", n = quote(family, &ix.name))], true, )), (Some(si), Some(ti)) if si.columns != ti.columns || si.unique != ti.unique => { items.push(item("index", &format!("{}.{}", s.name, si.name), "changed", vec![detail_line("columns", &ti.columns.join(", "), &si.columns.join(", "))], vec![format!("DROP INDEX {n}; CREATE {u}INDEX {n} ON {qtn} ({cols});", n = qualified(family, schema, &si.name), u = if si.unique { "UNIQUE " } else { "" }, cols = si.columns.iter().map(|c| quote(family, c)).collect::>().join(", "))], true)); } _ => {} } } } fn push_constraint_delta(items: &mut Vec, family: &str, table: &SnapshotTable, kind: &str, s_c: Option<&SnapshotConstraint>, t_c: Option<&SnapshotConstraint>) { let qtn = qualified(family, &table.schema, &table.name); match (s_c, t_c) { (Some(c), None) if kind == "UNIQUE" => items.push(item( "constraint", &format!("{}.{}", table.name, c.name), "added", vec![], vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &c.name), cols = c.definition.split(", ").map(|x| quote(family, x.trim())).collect::>().join(", "))], false, )), (Some(c), None) if kind == "CHECK" => items.push(item( "constraint", &format!("{}.{}", table.name, c.name), "added", vec![], vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &c.name), d = c.definition.trim())], false, )), (Some(c), None) if family == "sqlite" => items.push(item( "constraint", &format!("{}.{}", table.name, c.name), "added", vec![detail_line("note", "", "SQLite cannot add constraints to existing tables — table rebuild required")], vec![], false)), (None, Some(c)) => items.push(item( "constraint", &format!("{}.{}", table.name, c.name), "removed", vec![], vec![format!("ALTER TABLE {qtn} DROP CONSTRAINT {n};", n = quote(family, &c.name))], true, )), (Some(sc), Some(tc)) if sc.definition.trim() != tc.definition.trim() => { let sql = if kind == "UNIQUE" { format!("ALTER TABLE {qtn} DROP CONSTRAINT {n}; ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &sc.name), cols = sc.definition.split(", ").map(|x| quote(family, x.trim())).collect::>().join(", ")) } else { format!("ALTER TABLE {qtn} DROP CONSTRAINT {n}; ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &sc.name), d = sc.definition.trim()) }; items.push(item("constraint", &format!("{}.{}", table.name, sc.name), "changed", vec![detail_line("definition", &tc.definition, &sc.definition)], vec![sql], true)); } _ => {} } } impl SnapshotFk { fn definition_differs(&self, other: &SnapshotFk) -> bool { self.columns != other.columns || self.ref_table != other.ref_table || self.ref_columns != other.ref_columns || self.on_delete != other.on_delete || self.on_update != other.on_update } } #[cfg(test)] #[path = "schema_diff.test.rs"] mod schema_diff_tests;