use crate::audit::AuditDb;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestoreDialect {
MySql,
SQLite,
}
#[derive(Debug, Error)]
pub enum RestoreError {
#[error("backup #{0} not found")]
NotFound(i64),
#[error("restore planning failed: {0}")]
Plan(String),
#[error("restore execution failed: {0}")]
Execution(String),
}
#[derive(Debug, Clone)]
pub struct RestorePlan {
pub backup_id: i64,
pub statements: Vec<String>,
pub row_count: u64,
pub warnings: Vec<String>,
pub is_insert_hint_delete: bool,
}
#[derive(Debug, Clone)]
pub struct BackupDetail {
pub id: i64,
pub ts: String,
pub connection: String,
pub database: Option<String>,
pub table_name: String,
pub backup_kind: String,
pub rows_json: Option<String>,
pub schema_sql: Option<String>,
pub primary_key: Option<String>,
pub row_count: u64,
pub truncated: bool,
}
pub fn get_backup(audit: &Arc<AuditDb>, id: i64) -> Option<BackupDetail> {
audit
.with(|c| {
c.query_row(
"SELECT id, ts, connection, database, table_name, backup_kind,
rows_json, schema_sql, primary_key, row_count, truncated
FROM backup WHERE id = ?1",
[id],
|r| {
Ok(BackupDetail {
id: r.get(0)?,
ts: r.get(1)?,
connection: r.get(2)?,
database: r.get(3)?,
table_name: r.get(4)?,
backup_kind: r.get(5)?,
rows_json: r.get(6)?,
schema_sql: r.get(7)?,
primary_key: r.get(8)?,
row_count: r.get::<_, i64>(9)?.max(0) as u64,
truncated: r.get::<_, i64>(10)? != 0,
})
},
)
})
.ok()
}
fn quote_id(id: &str) -> String {
format!("`{}`", id.replace('`', "``"))
}
fn table_ref_sql(db: Option<&str>, table: &str) -> String {
match db {
Some(db) => format!("{}.{}", quote_id(db), quote_id(table)),
None => quote_id(table),
}
}
fn escape_value(v: &serde_json::Value, dialect: RestoreDialect) -> String {
match v {
serde_json::Value::Null => "NULL".into(),
serde_json::Value::Bool(b) => u8::from(*b).to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => {
let escaped = s.replace('\\', "\\\\").replace('\'', "''");
format!("'{escaped}'")
}
serde_json::Value::Object(o)
if o.get("type").and_then(|t| t.as_str()) == Some("binary")
&& o.get("encoding").and_then(|e| e.as_str()) == Some("base64") =>
{
use base64::Engine;
let data = o.get("data").and_then(|d| d.as_str()).unwrap_or("");
match base64::engine::general_purpose::STANDARD.decode(data) {
Ok(bytes) => {
let hex = bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
match dialect {
RestoreDialect::SQLite => format!("X'{hex}'"),
RestoreDialect::MySql => format!("0x{hex}"),
}
}
Err(_) => "NULL".into(),
}
}
other => {
let text = other.to_string();
let escaped = text.replace('\\', "\\\\").replace('\'', "''");
format!("'{escaped}'")
}
}
}
pub fn plan_restore(
audit: &Arc<AuditDb>,
id: i64,
dialect: RestoreDialect,
) -> Result<RestorePlan, RestoreError> {
let backup = get_backup(audit, id).ok_or(RestoreError::NotFound(id))?;
let mut statements: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut is_insert_hint_delete = false;
if backup.truncated {
warnings.push("backup was truncated; restore will not be complete".into());
}
if backup.backup_kind == "schema" {
if let Some(schema_sql) = &backup.schema_sql {
warnings.push(
"schema-only backup; running this will fail unless the table was dropped first"
.into(),
);
statements.push(format!("{schema_sql};"));
}
return Ok(RestorePlan {
backup_id: id,
statements,
row_count: 0,
warnings,
is_insert_hint_delete,
});
}
if backup.backup_kind == "combined"
&& let Some(schema_sql) = &backup.schema_sql
{
statements.push(format!("{schema_sql};"));
}
if backup.backup_kind == "insert-hint" {
let Some(pk_json) = &backup.primary_key else {
warnings.push(
"insert-hint backup has no recoverable PK metadata; nothing to restore".into(),
);
return Ok(RestorePlan {
backup_id: id,
statements,
row_count: 0,
warnings,
is_insert_hint_delete,
});
};
let tref = table_ref_sql(backup.database.as_deref(), &backup.table_name);
let pk: serde_json::Value = serde_json::from_str(pk_json)
.map_err(|e| RestoreError::Plan(format!("bad PK metadata: {e}")))?;
match pk["kind"].as_str() {
Some("range") => {
let column = pk["column"].as_str().unwrap_or_default();
let start = pk["start"].clone();
let end = pk["end"].clone();
statements.push(format!(
"DELETE FROM {tref} WHERE {} BETWEEN {} AND {};",
quote_id(column),
escape_value(&start, dialect),
escape_value(&end, dialect),
));
}
Some("explicit") => {
let columns: Vec<String> = pk["columns"]
.as_array()
.map(|a| a.iter().filter_map(|c| c.as_str().map(quote_id)).collect())
.unwrap_or_default();
let rows = pk["values"].as_array().map(|a| {
a.iter()
.map(|row| {
let vals: Vec<String> = row
.as_array()
.map(|r| r.iter().map(|v| escape_value(v, dialect)).collect())
.unwrap_or_default();
format!("({})", vals.join(", "))
})
.collect::<Vec<_>>()
.join(", ")
});
if !columns.is_empty()
&& let Some(rows) = rows
&& !rows.is_empty()
{
statements.push(format!(
"DELETE FROM {tref} WHERE ({}) IN ({rows});",
columns.join(", ")
));
}
}
_ => {}
}
warnings.push(
"insert-hint restore deletes the rows the INSERT created — verify before running"
.into(),
);
is_insert_hint_delete = true;
return Ok(RestorePlan {
backup_id: id,
statements,
row_count: backup.row_count,
warnings,
is_insert_hint_delete,
});
}
if let Some(rows_json) = &backup.rows_json {
let rows: Vec<serde_json::Value> = serde_json::from_str(rows_json)
.map_err(|e| RestoreError::Plan(format!("bad rows payload: {e}")))?;
if let Some(first) = rows.first().and_then(|r| r.as_object()) {
let cols: Vec<String> = first.keys().cloned().collect();
let col_list = cols
.iter()
.map(|c| quote_id(c))
.collect::<Vec<_>>()
.join(", ");
let update_clause = cols
.iter()
.map(|c| match dialect {
RestoreDialect::SQLite => format!("{} = excluded.{}", quote_id(c), quote_id(c)),
RestoreDialect::MySql => format!("{} = VALUES({})", quote_id(c), quote_id(c)),
})
.collect::<Vec<_>>()
.join(", ");
let tref = table_ref_sql(backup.database.as_deref(), &backup.table_name);
for row in &rows {
let Some(obj) = row.as_object() else { continue };
let values = cols
.iter()
.map(|c| escape_value(obj.get(c).unwrap_or(&serde_json::Value::Null), dialect))
.collect::<Vec<_>>()
.join(", ");
statements.push(match dialect {
RestoreDialect::SQLite => format!(
"INSERT INTO {tref} ({col_list}) VALUES ({values}) ON CONFLICT DO UPDATE SET {update_clause};"
),
RestoreDialect::MySql => format!(
"INSERT INTO {tref} ({col_list}) VALUES ({values}) ON DUPLICATE KEY UPDATE {update_clause};"
),
});
}
}
}
Ok(RestorePlan {
backup_id: id,
statements,
row_count: backup.row_count,
warnings,
is_insert_hint_delete,
})
}
#[derive(Debug)]
pub struct RestoreOutcome {
pub statements_run: usize,
pub affected: u64,
}
pub fn execute_restore_sqlite(
db: &rusqlite::Connection,
plan: &RestorePlan,
) -> Result<RestoreOutcome, RestoreError> {
let mut affected: u64 = 0;
for stmt_raw in &plan.statements {
let stmt_text = stmt_raw.trim_end_matches(|c: char| c == ';' || c.is_whitespace());
let mut stmt = db
.prepare(stmt_text)
.map_err(|e| RestoreError::Execution(e.to_string()))?;
if stmt.column_count() > 0 {
let rows = stmt
.query(())
.map_err(|e| RestoreError::Execution(e.to_string()))?;
drop(rows);
} else {
stmt.execute(())
.map_err(|e| RestoreError::Execution(e.to_string()))?;
affected += db.changes();
}
}
Ok(RestoreOutcome {
statements_run: plan.statements.len(),
affected,
})
}