use anyhow::{Context, Result, bail};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Table {
pub schema: String,
pub name: String,
}
impl std::fmt::Display for Table {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}", self.schema, self.name)
}
}
#[derive(Debug, Clone)]
pub struct Column {
pub name: String,
pub udt: String,
}
pub async fn source_tables(pool: &sqlx::SqlitePool, modules: &[&str]) -> Result<Vec<Table>> {
let mut tables = Vec::new();
for module in modules {
let sql = format!(
"SELECT name FROM {module}.sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name"
);
let names: Vec<(String,)> = sqlx::query_as(&sql)
.fetch_all(pool)
.await
.with_context(|| format!("list tables in sqlite database {module}"))?;
tables.extend(names.into_iter().map(|(name,)| Table {
schema: (*module).to_string(),
name,
}));
}
Ok(tables)
}
pub async fn source_columns(pool: &sqlx::SqlitePool, table: &Table) -> Result<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info(?, ?)")
.bind(&table.name)
.bind(&table.schema)
.fetch_all(pool)
.await
.with_context(|| format!("read columns of {table}"))?;
Ok(rows.into_iter().map(|(n,)| n).collect())
}
pub async fn target_columns(pool: &sqlx::PgPool, table: &Table) -> Result<Vec<Column>> {
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT column_name, udt_name FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
ORDER BY ordinal_position",
)
.bind(&table.schema)
.bind(&table.name)
.fetch_all(pool)
.await
.with_context(|| format!("read postgres columns of {table}"))?;
Ok(rows
.into_iter()
.map(|(name, udt)| Column { name, udt })
.collect())
}
pub async fn non_empty_tables(pool: &sqlx::PgPool, schemas: &[&str]) -> Result<Vec<(Table, i64)>> {
let owned: Vec<String> = schemas.iter().map(|s| (*s).to_string()).collect();
let existing: Vec<(String, String)> = sqlx::query_as(
"SELECT table_schema, table_name FROM information_schema.tables
WHERE table_schema = ANY($1) AND table_type = 'BASE TABLE'
ORDER BY table_schema, table_name",
)
.bind(&owned)
.fetch_all(pool)
.await
.context("list existing tables in the target")?;
let mut occupied = Vec::new();
for (schema, name) in existing {
let table = Table { schema, name };
let count = count_rows_pg(pool, &table).await?;
if count > 0 {
occupied.push((table, count));
}
}
Ok(occupied)
}
pub fn quoted(table: &Table) -> String {
format!(r#""{}"."{}""#, table.schema, table.name)
}
fn count_sql(table: &Table) -> String {
format!("SELECT COUNT(*) FROM {}", quoted(table))
}
pub async fn count_rows_pg(pool: &sqlx::PgPool, table: &Table) -> Result<i64> {
let count = sqlx::query_scalar(&count_sql(table)).fetch_one(pool).await;
count.with_context(|| format!("count rows of {table}"))
}
pub async fn count_rows_sqlite(pool: &sqlx::SqlitePool, table: &Table) -> Result<i64> {
let count = sqlx::query_scalar(&count_sql(table)).fetch_one(pool).await;
count.with_context(|| format!("count rows of {table}"))
}
pub async fn insert_order(pool: &sqlx::PgPool, tables: &[Table]) -> Result<Vec<Table>> {
let schemas: Vec<String> = tables
.iter()
.map(|t| t.schema.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
let edges: Vec<(String, String, String, String)> = sqlx::query_as(
"SELECT ns.nspname, cl.relname, fns.nspname, fcl.relname
FROM pg_constraint c
JOIN pg_class cl ON cl.oid = c.conrelid
JOIN pg_namespace ns ON ns.oid = cl.relnamespace
JOIN pg_class fcl ON fcl.oid = c.confrelid
JOIN pg_namespace fns ON fns.oid = fcl.relnamespace
WHERE c.contype = 'f' AND ns.nspname = ANY($1)",
)
.bind(&schemas)
.fetch_all(pool)
.await
.context("read foreign keys from the target")?;
let present: HashSet<Table> = tables.iter().cloned().collect();
let mut blockers: HashMap<Table, HashSet<Table>> =
tables.iter().map(|t| (t.clone(), HashSet::new())).collect();
for (schema, name, ref_schema, ref_name) in edges {
let child = Table { schema, name };
let parent = Table {
schema: ref_schema,
name: ref_name,
};
if child == parent || !present.contains(&child) || !present.contains(&parent) {
continue;
}
blockers.entry(child).or_default().insert(parent);
}
let mut ordered: Vec<Table> = Vec::with_capacity(tables.len());
let mut placed: HashSet<Table> = HashSet::new();
while ordered.len() < tables.len() {
let ready: Vec<Table> = tables
.iter()
.filter(|t| !placed.contains(*t))
.filter(|t| blockers[*t].iter().all(|p| placed.contains(p)))
.cloned()
.collect();
if ready.is_empty() {
let stuck: Vec<String> = tables
.iter()
.filter(|t| !placed.contains(*t))
.map(|t| t.to_string())
.collect();
bail!("foreign keys form a cycle across {}", stuck.join(", "));
}
for table in ready {
placed.insert(table.clone());
ordered.push(table);
}
}
Ok(ordered)
}
pub async fn resync_sequences(pool: &sqlx::PgPool, tables: &[Table]) -> Result<usize> {
let mut resynced = 0;
for table in tables {
for column in target_columns(pool, table).await? {
let qualified = format!(r#""{}"."{}""#, table.schema, table.name);
let sequence: Option<String> = sqlx::query_scalar("SELECT pg_get_serial_sequence($1, $2)")
.bind(&qualified)
.bind(&column.name)
.fetch_one(pool)
.await
.with_context(|| format!("resolve sequence for {table}.{}", column.name))?;
let Some(sequence) = sequence else { continue };
let sql = format!(
r#"SELECT setval('{sequence}', COALESCE((SELECT MAX("{}") FROM {qualified}), 0) + 1, false)"#,
column.name
);
sqlx::query(&sql)
.execute(pool)
.await
.with_context(|| format!("resync sequence {sequence}"))?;
resynced += 1;
}
}
Ok(resynced)
}