0.8.0: MariaDB + PlanetScale, TimescaleDB, multi-result sets, streaming export, schema diff (#27)
This commit was merged in pull request #27.
This commit is contained in:
@@ -887,3 +887,123 @@ mod tests {
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimescaleDB (hypertables)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect whether the timescaledb extension is installed on this database.
|
||||
pub fn pg_timescale_detect_query() -> String {
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')".to_string()
|
||||
}
|
||||
|
||||
/// Query hypertables in a schema (TimescaleDB 2.x information views).
|
||||
/// Columns: name, schema, num_dimensions, compression_enabled, num_chunks, total_size_bytes.
|
||||
pub fn pg_hypertables_query(_schema: &str) -> String {
|
||||
"SELECT h.hypertable_name, h.hypertable_schema, \
|
||||
h.num_dimensions, \
|
||||
COALESCE(h.compression_enabled, false), \
|
||||
(SELECT COUNT(*) FROM timescaledb_information.chunks c \
|
||||
WHERE c.hypertable_schema = h.hypertable_schema \
|
||||
AND c.hypertable_name = h.hypertable_name), \
|
||||
pg_total_relation_size(format('%I.%I', h.hypertable_schema, h.hypertable_name)::regclass)::bigint \
|
||||
FROM timescaledb_information.hypertables h \
|
||||
WHERE h.hypertable_schema = $1 \
|
||||
ORDER BY h.hypertable_name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema-diff snapshot queries (0.8.0) — one set-based query per object class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Table columns with resolved types, nullability, and defaults.
|
||||
/// Columns: table_name, column_name, data_type, is_nullable, default_expr
|
||||
pub fn pg_diff_columns_query() -> String {
|
||||
"SELECT c.relname, a.attname, \
|
||||
pg_catalog.format_type(a.atttypid, a.atttypmod), \
|
||||
NOT a.attnotnull, \
|
||||
pg_catalog.pg_get_expr(d.adbin, d.adrelid) \
|
||||
FROM pg_catalog.pg_class c \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
||||
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped \
|
||||
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum \
|
||||
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p') \
|
||||
ORDER BY c.relname, a.attnum"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Constraints (PK/UNIQUE/CHECK/FK) with exact column lists and FK metadata.
|
||||
/// Columns: table_name, constraint_name, contype, cols, ref_cols, ref_table, confdeltype, confupdtype, definition
|
||||
pub fn pg_diff_constraints_query() -> String {
|
||||
"SELECT cl.relname, con.conname, con.contype::text, \
|
||||
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||
FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum), \
|
||||
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||
FROM unnest(con.confkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||
JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.attnum), \
|
||||
(SELECT n2.nspname || '.' || cl2.relname \
|
||||
FROM pg_class cl2 JOIN pg_namespace n2 ON n2.oid = cl2.relnamespace \
|
||||
WHERE cl2.oid = con.confrelid), \
|
||||
con.confdeltype::text, con.confupdtype::text, \
|
||||
pg_get_constraintdef(con.oid, true) \
|
||||
FROM pg_catalog.pg_constraint con \
|
||||
JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace \
|
||||
WHERE n.nspname = $1 AND con.contype IN ('p', 'u', 'c', 'f') \
|
||||
ORDER BY cl.relname, con.contype, con.conname"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Secondary indexes (constraint-backed indexes excluded), with column lists.
|
||||
/// Columns: table_name, index_name, is_unique, cols
|
||||
pub fn pg_diff_indexes_query() -> String {
|
||||
"SELECT t.relname, i.relname, ix.indisunique, \
|
||||
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||
FROM unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||
JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum \
|
||||
WHERE k.attnum > 0) \
|
||||
FROM pg_index ix \
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
||||
WHERE n.nspname = $1 AND NOT ix.indisprimary \
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.oid) \
|
||||
ORDER BY t.relname, i.relname"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Views + materialized views. Columns: schema, name, definition, materialized
|
||||
pub fn pg_diff_views_query() -> String {
|
||||
"SELECT v.schemaname, v.viewname, v.definition, false \
|
||||
FROM pg_views v WHERE v.schemaname = $1 \
|
||||
UNION ALL \
|
||||
SELECT v.schemaname, v.matviewname, v.definition, true \
|
||||
FROM pg_matviews v WHERE v.schemaname = $1 \
|
||||
ORDER BY 2"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Sequences. Columns: schema, name, start, increment, min, max, cycle
|
||||
pub fn pg_diff_sequences_query() -> String {
|
||||
"SELECT sequence_schema, sequence_name, \
|
||||
COALESCE(start_value::text, '1'), COALESCE(increment::text, '1'), \
|
||||
COALESCE(minimum_value::text, '1'), COALESCE(maximum_value::text, '9223372036854775807'), \
|
||||
cycle_option = 'YES' \
|
||||
FROM information_schema.sequences \
|
||||
WHERE sequence_schema = $1 \
|
||||
ORDER BY sequence_name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Enums with ordered labels. Columns: schema, name, label (row per label)
|
||||
pub fn pg_diff_enums_query() -> String {
|
||||
"SELECT n.nspname, t.typname, e.enumlabel \
|
||||
FROM pg_type t \
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace \
|
||||
JOIN pg_enum e ON e.enumtypid = t.oid \
|
||||
WHERE t.typtype = 'e' AND n.nspname = $1 \
|
||||
ORDER BY t.typname, e.enumsortorder"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod sql_split;
|
||||
pub mod object_crud;
|
||||
pub mod object_ddl;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
pub mod schema_diff;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
|
||||
@@ -323,4 +323,64 @@ mod tests {
|
||||
assert_eq!(mysql_default_sort(&["name".into()]), "name");
|
||||
assert_eq!(mysql_default_sort(&[]), "");
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Schema-diff snapshot queries (0.8.0) ──────────────────────────────
|
||||
|
||||
/// Columns: table_name, column_name, column_type, is_nullable, column_default
|
||||
pub fn mysql_snapshot_columns_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT table_name, column_name, column_type, is_nullable, \
|
||||
COALESCE(column_default, '') \
|
||||
FROM information_schema.columns \
|
||||
WHERE table_schema = '{}' \
|
||||
ORDER BY table_name, ordinal_position",
|
||||
schema.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Constraints (one row per constraint-column). Columns: table_name,
|
||||
/// constraint_name, constraint_type, column_name, referenced_table_name,
|
||||
/// delete_rule, update_rule, check_clause
|
||||
pub fn mysql_snapshot_constraints_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT kcu.table_name, kcu.constraint_name, tc.constraint_type, kcu.column_name, \
|
||||
COALESCE(kcu.referenced_table_name, ''), \
|
||||
COALESCE(rc.delete_rule, ''), COALESCE(rc.update_rule, ''), \
|
||||
COALESCE((SELECT cc.check_clause FROM information_schema.check_constraints cc \
|
||||
WHERE cc.constraint_schema = kcu.constraint_schema \
|
||||
AND cc.constraint_name = kcu.constraint_name), '') \
|
||||
FROM information_schema.key_column_usage kcu \
|
||||
LEFT JOIN information_schema.table_constraints tc \
|
||||
ON tc.constraint_schema = kcu.constraint_schema \
|
||||
AND tc.constraint_name = kcu.constraint_name \
|
||||
AND tc.table_name = kcu.table_name \
|
||||
LEFT JOIN information_schema.referential_constraints rc \
|
||||
ON rc.constraint_schema = kcu.constraint_schema \
|
||||
AND rc.constraint_name = kcu.constraint_name \
|
||||
WHERE kcu.constraint_schema = '{}' AND tc.constraint_type IS NOT NULL \
|
||||
ORDER BY kcu.table_name, kcu.constraint_name, kcu.ordinal_position",
|
||||
schema.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Secondary indexes (one row per index-column). Columns: table_name,
|
||||
/// index_name, non_unique, column_name
|
||||
pub fn mysql_snapshot_indexes_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT table_name, index_name, non_unique, column_name \
|
||||
FROM information_schema.statistics \
|
||||
WHERE table_schema = '{}' AND index_name != 'PRIMARY' \
|
||||
ORDER BY table_name, index_name, seq_in_index",
|
||||
schema.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Views. Columns: table_schema, table_name, view_definition
|
||||
pub fn mysql_snapshot_views_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT table_schema, table_name, view_definition \
|
||||
FROM information_schema.views \
|
||||
WHERE table_schema = '{}'",
|
||||
schema.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
//! 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<T>(v: &mut Vec<T>, 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<String> {
|
||||
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<SchemaSnapshot, String> {
|
||||
let mut tables: BTreeMap<String, SnapshotTable> = BTreeMap::new();
|
||||
let mut views: Vec<SnapshotView> = Vec::new();
|
||||
let mut sequences: Vec<SnapshotSequence> = Vec::new();
|
||||
let mut enums: BTreeMap<String, SnapshotEnum> = 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<String>>(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<String>>(3).unwrap_or_default()),
|
||||
"u" => entry.uniques.push(SnapshotConstraint {
|
||||
name: cname,
|
||||
kind: "unique".into(),
|
||||
definition: r.get::<_, Option<String>>(3).unwrap_or_default(),
|
||||
}),
|
||||
"c" => entry.checks.push(SnapshotConstraint {
|
||||
name: cname,
|
||||
kind: "check".into(),
|
||||
definition: r.get::<_, Option<String>>(8).unwrap_or_default(),
|
||||
}),
|
||||
"f" => {
|
||||
let ref_qualified: Option<String> = 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<String>>(3).unwrap_or_default()),
|
||||
ref_schema,
|
||||
ref_table,
|
||||
ref_columns: csv_names(&r.get::<_, Option<String>>(4).unwrap_or_default()),
|
||||
on_delete: pg_fk_action(&r.get::<_, Option<String>>(6).unwrap_or_default()).to_string(),
|
||||
on_update: pg_fk_action(&r.get::<_, Option<String>>(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<String>>(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<SchemaSnapshot, String> {
|
||||
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<SchemaSnapshot, String> {
|
||||
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<SchemaSnapshot, String> {
|
||||
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<SchemaSnapshot, String> {
|
||||
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<String>)> = 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<String>, 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<String> = 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<i64, SnapshotFk> = 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<SchemaSnapshot, String> {
|
||||
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<String, SnapshotTable> = BTreeMap::new();
|
||||
let mut views: Vec<SnapshotView> = 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<DiffDetailLine>, sql: Vec<String>, 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<DiffItem> = 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::<Vec<_>>().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<String> = 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::<Vec<_>>().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<K, (Option<&T>, 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<DiffItem>, 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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().join(", ");
|
||||
let ref_cols = fk.ref_columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().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<DiffItem>, 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::<Vec<_>>().join(", "),
|
||||
r = qualified(family, &sf.ref_schema, &sf.ref_table),
|
||||
rc = sf.ref_columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().join(", "))],
|
||||
true));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_constraint_delta(items: &mut Vec<DiffItem>, 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::<Vec<_>>().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::<Vec<_>>().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;
|
||||
@@ -0,0 +1,362 @@
|
||||
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<SnapshotColumn>) -> 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<SnapshotTable>) -> 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()));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Pure SQL statement splitter shared by multi-result execution and the
|
||||
//! streaming export (single-statement validation).
|
||||
//!
|
||||
//! A hand-rolled state machine (no new dependencies). It understands:
|
||||
//! single/double/backtick-quoted strings with doubled-quote escapes,
|
||||
//! `--` line comments, `/* */` block comments, and PostgreSQL dollar-quoted
|
||||
//! bodies (`$$…$$`, `$tag$…$tag$`). Conservative by design: anything it
|
||||
//! cannot confidently close is kept inside the current statement rather
|
||||
//! than split — a naive semicolon split that could execute fragmentary SQL
|
||||
//! is unacceptable (spec §2).
|
||||
//!
|
||||
//! Statement classification (SELECT vs DML vs DDL) happens at execution
|
||||
//! time, not here.
|
||||
|
||||
/// Split `sql` into ordered statement texts. Comment-only and empty
|
||||
/// fragments are dropped. Statement text keeps its comments verbatim.
|
||||
pub fn split_statements(sql: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = sql.chars().collect();
|
||||
let mut stmts: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
match c {
|
||||
'\'' | '"' | '`' => {
|
||||
copy_quoted(&chars, &mut i, &mut current, c);
|
||||
}
|
||||
'-' if peek(&chars, i + 1) == Some('-') => {
|
||||
copy_until(&chars, &mut i, &mut current, |ch| ch == '\n');
|
||||
}
|
||||
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||
copy_block_comment(&chars, &mut i, &mut current);
|
||||
}
|
||||
'$' => {
|
||||
if let Some(end) = dollar_quote_end(&chars, i) {
|
||||
for _ in i..end {
|
||||
if i < chars.len() {
|
||||
current.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
';' => {
|
||||
if has_executable_text(¤t) {
|
||||
stmts.push(current.trim().to_string());
|
||||
}
|
||||
current.clear();
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
current.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_executable_text(¤t) {
|
||||
stmts.push(current.trim().to_string());
|
||||
}
|
||||
stmts
|
||||
}
|
||||
|
||||
fn peek(chars: &[char], idx: usize) -> Option<char> {
|
||||
chars.get(idx).copied()
|
||||
}
|
||||
|
||||
/// Copy a quoted region starting at the opening `quote` char. Doubled quotes
|
||||
/// are escapes and stay inside the region. Unterminated quotes consume the
|
||||
/// rest of the input (conservative).
|
||||
fn copy_quoted(chars: &[char], i: &mut usize, out: &mut String, quote: char) {
|
||||
out.push(chars[*i]);
|
||||
*i += 1;
|
||||
while *i < chars.len() {
|
||||
let c = chars[*i];
|
||||
out.push(c);
|
||||
*i += 1;
|
||||
if c == quote {
|
||||
if peek(chars, *i) == Some(quote) {
|
||||
// doubled quote — escaped, keep it and continue
|
||||
out.push(quote);
|
||||
*i += 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy characters until `stop` matches (inclusive for `\n` handled by the
|
||||
/// caller's predicate) or input ends.
|
||||
fn copy_until(chars: &[char], i: &mut usize, out: &mut String, stop: impl Fn(char) -> bool) {
|
||||
while *i < chars.len() {
|
||||
let c = chars[*i];
|
||||
out.push(c);
|
||||
*i += 1;
|
||||
if stop(c) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a `/* … */` block comment (no nesting per the SQL standard).
|
||||
fn copy_block_comment(chars: &[char], i: &mut usize, out: &mut String) {
|
||||
out.push(chars[*i]); // '/'
|
||||
*i += 1;
|
||||
if *i < chars.len() {
|
||||
out.push(chars[*i]); // '*'
|
||||
*i += 1;
|
||||
}
|
||||
while *i < chars.len() {
|
||||
let c = chars[*i];
|
||||
out.push(c);
|
||||
*i += 1;
|
||||
if c == '*' && peek(chars, *i) == Some('/') {
|
||||
out.push('/');
|
||||
*i += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// At `chars[start] == '$'`, try to match `$[A-Za-z_][A-Za-z0-9_]*$` and find
|
||||
/// the matching closing delimiter. Returns the index one past the closing
|
||||
/// delimiter, or None if this is not a well-formed (and closable) dollar
|
||||
/// quote — in which case the `$` is ordinary text.
|
||||
fn dollar_quote_end(chars: &[char], start: usize) -> Option<usize> {
|
||||
let mut j = start + 1;
|
||||
if j < chars.len() && chars[j] == '$' {
|
||||
// bare $$ body
|
||||
let tag = String::new();
|
||||
return find_dollar_close(chars, j + 1, &tag);
|
||||
}
|
||||
let mut tag = String::new();
|
||||
while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
|
||||
tag.push(chars[j]);
|
||||
j += 1;
|
||||
}
|
||||
if tag.is_empty() || j >= chars.len() || chars[j] != '$' {
|
||||
return None; // not $tag$
|
||||
}
|
||||
find_dollar_close(chars, j + 1, &tag)
|
||||
}
|
||||
|
||||
fn find_dollar_close(chars: &[char], from: usize, tag: &str) -> Option<usize> {
|
||||
let closing: Vec<char> = format!("${tag}$").chars().collect();
|
||||
let mut j = from;
|
||||
while j + closing.len() <= chars.len() {
|
||||
if chars[j..j + closing.len()] == closing[..] {
|
||||
return Some(j + closing.len());
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True if the fragment contains anything other than whitespace and comments
|
||||
/// (comments are preserved in statement text, but a fragment that is ONLY
|
||||
/// comments/whitespace is not a statement).
|
||||
pub fn has_executable_text(stmt: &str) -> bool {
|
||||
let chars: Vec<char> = stmt.chars().collect();
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
match c {
|
||||
'\'' | '"' | '`' => {
|
||||
let mut probe = String::new();
|
||||
let mut j = i;
|
||||
copy_quoted(&chars, &mut j, &mut probe, c);
|
||||
return true; // a quoted region IS executable text
|
||||
}
|
||||
'-' if peek(&chars, i + 1) == Some('-') => {
|
||||
skip_until(&chars, &mut i, |ch| ch == '\n');
|
||||
}
|
||||
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||
skip_block_comment(&chars, &mut i);
|
||||
}
|
||||
c if c.is_whitespace() => i += 1,
|
||||
_ => return true,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn skip_until(chars: &[char], i: &mut usize, stop: impl Fn(char) -> bool) {
|
||||
while *i < chars.len() {
|
||||
let c = chars[*i];
|
||||
*i += 1;
|
||||
if stop(c) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_block_comment(chars: &[char], i: &mut usize) {
|
||||
*i += 2; // "/*"
|
||||
while *i < chars.len() {
|
||||
if chars[*i] == '*' && peek(chars, *i + 1) == Some('/') {
|
||||
*i += 2;
|
||||
return;
|
||||
}
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sql_split.test.rs"]
|
||||
mod sql_split_tests;
|
||||
@@ -0,0 +1,86 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_two_statements() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT 1; SELECT 2"),
|
||||
vec!["SELECT 1", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_trailing_semicolon_is_fine() {
|
||||
assert_eq!(split_statements("SELECT 1"), vec!["SELECT 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semicolon_inside_string_is_not_a_boundary() {
|
||||
assert_eq!(split_statements("SELECT 'a;b'"), vec!["SELECT 'a;b'"]);
|
||||
assert_eq!(
|
||||
split_statements("SELECT 'a;b'; SELECT 2"),
|
||||
vec!["SELECT 'a;b'", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_quote_escapes() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT 'it''s'; SELECT 2"),
|
||||
vec!["SELECT 'it''s'", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_quoted_and_backtick_identifiers() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT \"a;b\"; SELECT `x;y`"),
|
||||
vec!["SELECT \"a;b\"", "SELECT `x;y`"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_statements("SELECT `a``b`; SELECT 2"),
|
||||
vec!["SELECT `a``b`", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_comments_do_not_break_statements() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT 1 -- ; not a boundary\n; SELECT 2"),
|
||||
vec!["SELECT 1 -- ; not a boundary", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_comments_do_not_break_statements() {
|
||||
assert_eq!(split_statements("SELECT /* ; */ 1"), vec!["SELECT /* ; */ 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollar_quoted_bodies() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT $$a;b$$; SELECT 2"),
|
||||
vec!["SELECT $$a;b$$", "SELECT 2"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_statements("SELECT $fn$x;y$fn$; SELECT 2"),
|
||||
vec!["SELECT $fn$x;y$fn$", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_dollar_is_literal() {
|
||||
assert_eq!(split_statements("SELECT a$b"), vec!["SELECT a$b"]);
|
||||
assert_eq!(split_statements("SELECT 1$"), vec!["SELECT 1$"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_only_statements_are_skipped() {
|
||||
assert_eq!(split_statements("-- nothing\n; SELECT 1"), vec!["SELECT 1"]);
|
||||
assert_eq!(split_statements("/* c */"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_whitespace_input() {
|
||||
assert_eq!(split_statements(""), Vec::<String>::new());
|
||||
assert_eq!(split_statements(" ; ; "), Vec::<String>::new());
|
||||
}
|
||||
Reference in New Issue
Block a user