use crate::preview::Preview;
use std::process::Command;
#[derive(Debug, PartialEq)]
pub enum Destructive {
DropTable {
tables: Vec<String>,
cascade: bool,
if_exists: bool,
},
Truncate {
tables: Vec<String>,
cascade: bool,
},
DeleteFrom {
table: String,
has_where: bool,
},
}
pub fn preview_for(command: &str, cwd: &std::path::Path, live: bool) -> Option<Preview> {
let tokens = shell_tokens(command);
psql_program(&tokens)?; let sql = extract_sql(&tokens)?;
let stmts = parse_destructive(&sql);
if stmts.is_empty() {
return None; }
let mut lines = Vec::new();
let mut summary_parts = Vec::new();
let mut status = LiveStatus::NotAttempted;
for stmt in stmts.iter().take(3) {
match stmt {
Destructive::DropTable {
tables, cascade, ..
} => {
for t in tables {
lines.push(format!(
" DROP TABLE {}{}",
t,
if *cascade { " CASCADE" } else { "" }
));
let info = ask(live, command, t, &mut status);
if let Some(info) = &info {
lines.push(format!(" rows (estimate) : {}", info.rows_display()));
if info.dependents.is_empty() {
lines.push(" referenced by : nothing — no FK dependents".into());
} else {
lines.push(format!(
" referenced by : {} ({} table{})",
info.dependents.join(", "),
info.dependents.len(),
if info.dependents.len() == 1 { "" } else { "s" }
));
if *cascade {
lines.push(" CASCADE effect : drops the FK constraints in those tables".into());
} else {
lines.push(
" without CASCADE : this DROP will FAIL (dependents exist)"
.into(),
);
}
}
summary_parts.push(format!(
"DROP {} ~{} rows, {} dependent(s)",
t,
info.rows_display(),
info.dependents.len()
));
} else {
summary_parts.push(format!("DROP {}", t));
}
}
}
Destructive::Truncate { tables, cascade } => {
for t in tables {
lines.push(format!(
" TRUNCATE {}{}",
t,
if *cascade { " CASCADE" } else { "" }
));
if let Some(info) = ask(live, command, t, &mut status) {
lines.push(format!(
" rows to erase (estimate) : {}",
info.rows_display()
));
if !info.dependents.is_empty() && !*cascade {
lines.push(format!(
" without CASCADE : will FAIL — referenced by {}",
info.dependents.join(", ")
));
}
summary_parts.push(format!("TRUNCATE {} ~{} rows", t, info.rows_display()));
} else {
summary_parts.push(format!("TRUNCATE {}", t));
}
}
}
Destructive::DeleteFrom { table, has_where } => {
if *has_where {
lines.push(format!(" DELETE FROM {} (filtered by WHERE)", table));
lines.push(
" affected rows depend on the filter — cannot estimate cheaply".into(),
);
summary_parts.push(format!("DELETE FROM {} (filtered)", table));
} else {
lines.push(format!(
" DELETE FROM {} — NO WHERE CLAUSE (deletes every row)",
table
));
if let Some(info) = ask(live, command, table, &mut status) {
lines.push(format!(
" rows to delete (estimate) : {}",
info.rows_display()
));
summary_parts.push(format!(
"DELETE ALL from {} ~{} rows",
table,
info.rows_display()
));
} else {
summary_parts.push(format!("DELETE ALL from {}", table));
}
}
}
}
}
let uninsurable = crate::backup::plan(command, cwd).is_none();
match crate::backup::plan(command, cwd) {
Some(plan) => lines.push(format!(" insurance : {} (automatic on run/hook)", plan)),
None => lines.push(" insurance : none — not reversible without a backup".into()),
}
match status {
LiveStatus::Reached => {}
LiveStatus::NotAttempted if !live => lines.push(
" (database not consulted — live introspection is skipped for a denied command)"
.into(),
),
LiveStatus::NotAttempted => lines
.push(" (database not consulted — no statement here has a cheap row estimate)".into()),
LiveStatus::Failed(why) => {
lines.push(format!(" (live introspection failed — {why})"));
lines.push(" (static analysis only)".into());
}
}
Some(Preview {
title: "postgres impact".into(),
lines,
summary: summary_parts.join("; "),
uninsurable,
})
}
enum LiveStatus {
NotAttempted,
Failed(LiveError),
Reached,
}
fn ask(live: bool, command: &str, table: &str, status: &mut LiveStatus) -> Option<TableInfo> {
if !live {
return None;
}
match introspect(command, table) {
Ok(info) => {
*status = LiveStatus::Reached;
Some(info)
}
Err(why) => {
if matches!(status, LiveStatus::NotAttempted) {
*status = LiveStatus::Failed(why);
}
None
}
}
}
#[derive(Debug, PartialEq)]
pub enum LiveError {
Spawn { prog: String, detail: String },
Exit { code: Option<i32>, detail: String },
Output { seen: String },
}
impl std::fmt::Display for LiveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LiveError::Spawn { prog, detail } => write!(f, "could not start {prog}: {detail}"),
LiveError::Exit { code, detail } => {
match code {
Some(c) => write!(f, "psql exited {c}: {detail}")?,
None => write!(f, "psql was killed by a signal: {detail}")?,
}
if detail.contains("no password supplied") {
write!(
f,
" — the preview never prompts; set PGPASSWORD or ~/.pgpass"
)?;
}
Ok(())
}
LiveError::Output { seen } => {
write!(
f,
"psql answered but no row estimate was found; first line: {seen}"
)
}
}
}
}
pub fn fk_dependents(original_command: &str, table: &str) -> Vec<String> {
introspect(original_command, table)
.map(|i| i.dependents)
.unwrap_or_default()
}
struct TableInfo {
rows: i64, dependents: Vec<String>,
}
impl TableInfo {
fn rows_display(&self) -> String {
if self.rows < 0 {
"unknown (never analyzed)".into()
} else {
group_thousands(self.rows)
}
}
}
fn introspect(original_command: &str, table: &str) -> Result<TableInfo, LiveError> {
let esc = table.replace('\'', "''"); let statements = [
"SET default_transaction_read_only = on;".to_string(),
format!(
"SELECT COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid = '{esc}'::regclass), -1);"
),
format!(
"SELECT COALESCE(string_agg(DISTINCT c.conrelid::regclass::text, ','), '') \
FROM pg_constraint c WHERE c.contype = 'f' AND c.confrelid = '{esc}'::regclass;"
),
];
let tokens = shell_tokens(original_command);
let prog = psql_program(&tokens).ok_or_else(|| LiveError::Spawn {
prog: tokens.first().cloned().unwrap_or_default(),
detail: "not a psql command".into(),
})?;
let mut args = connection_args(&tokens);
args.extend(
["-w", "-t", "-A", "-X", "-v", "ON_ERROR_STOP=1"]
.iter()
.map(|s| s.to_string()),
);
for stmt in statements {
args.push("-c".into());
args.push(stmt);
}
let out = Command::new(&prog)
.args(&args)
.env("PGCONNECT_TIMEOUT", "3")
.output()
.map_err(|e| LiveError::Spawn {
prog: prog.clone(),
detail: e.to_string(),
})?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let detail = stderr
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("(nothing on stderr)")
.to_string();
return Err(LiveError::Exit {
code: out.status.code(),
detail,
});
}
let text = String::from_utf8_lossy(&out.stdout);
let mut lines = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && *l != "SET");
let first = lines.next().unwrap_or("(no output)");
let rows: i64 = first.parse().map_err(|_| LiveError::Output {
seen: first.to_string(),
})?;
let dependents: Vec<String> = lines
.next()
.map(|l| {
l.trim()
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
Ok(TableInfo { rows, dependents })
}
pub fn psql_program(tokens: &[String]) -> Option<String> {
let first = tokens.first()?;
let name = first.rsplit(['/', '\\']).next().unwrap_or(first);
let stem = name.strip_suffix(".exe").unwrap_or(name);
if stem == "psql" {
Some(first.clone())
} else {
None
}
}
const PSQL_VALUE_SHORT: &[char] = &[
'c', 'd', 'f', 'v', 'L', 'o', 'F', 'P', 'R', 'T', 'h', 'p', 'U',
];
const PSQL_VALUE_LONG: &[&str] = &[
"command",
"dbname",
"file",
"set",
"variable",
"log-file",
"output",
"field-separator",
"pset",
"record-separator",
"table-attr",
"host",
"port",
"username",
];
pub fn connection_args(tokens: &[String]) -> Vec<String> {
let mut host: Option<String> = None;
let mut port: Option<String> = None;
let mut user: Option<String> = None;
let mut dbname: Option<String> = None;
let mut positionals: Vec<String> = Vec::new();
let mut i = 1; while i < tokens.len() {
let t = &tokens[i];
if let Some(body) = t.strip_prefix("--") {
let (name, inline) = match body.split_once('=') {
Some((n, v)) => (n, Some(v.to_string())),
None => (body, None),
};
let takes_value = PSQL_VALUE_LONG.contains(&name);
let value = match (inline, takes_value) {
(Some(v), _) => Some(v),
(None, true) => {
i += 1;
tokens.get(i).cloned()
}
(None, false) => None,
};
match name {
"host" => host = value,
"port" => port = value,
"username" => user = value,
"dbname" => dbname = value,
_ => {} }
i += 1;
continue;
}
if t.starts_with('-') && t.len() > 1 {
let chars: Vec<char> = t.chars().skip(1).collect();
let mut j = 0;
while j < chars.len() {
let f = chars[j];
if PSQL_VALUE_SHORT.contains(&f) {
let rest: String = chars[j + 1..].iter().collect();
let value = if rest.is_empty() {
i += 1;
tokens.get(i).cloned()
} else {
Some(rest)
};
match f {
'h' => host = value,
'p' => port = value,
'U' => user = value,
'd' => dbname = value,
_ => {} }
break; }
j += 1; }
i += 1;
continue;
}
positionals.push(t.clone());
i += 1;
}
if dbname.is_none() {
dbname = positionals.first().cloned();
}
if user.is_none() {
user = positionals.get(1).cloned();
}
let mut out = Vec::new();
for (flag, value) in [("-h", host), ("-p", port), ("-U", user), ("-d", dbname)] {
if let Some(v) = value {
if !v.is_empty() {
out.push(flag.to_string());
out.push(v);
}
}
}
out
}
pub fn shell_tokens(s: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut cur = String::new();
let mut chars = s.chars().peekable();
let mut in_single = false;
let mut in_double = false;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'\\' if in_double => {
if let Some(&n) = chars.peek() {
cur.push(n);
chars.next();
}
}
c if c.is_whitespace() && !in_single && !in_double => {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
}
fn extract_sql(tokens: &[String]) -> Option<String> {
let mut i = 0;
while i < tokens.len() {
if tokens[i] == "-c" || tokens[i] == "--command" {
return tokens.get(i + 1).cloned();
}
if let Some(rest) = tokens[i].strip_prefix("--command=") {
return Some(rest.to_string());
}
i += 1;
}
None
}
pub fn parse_destructive(sql: &str) -> Vec<Destructive> {
sql.split(';').filter_map(parse_one).collect()
}
fn parse_one(stmt: &str) -> Option<Destructive> {
let words: Vec<String> = stmt.split_whitespace().map(|w| w.to_string()).collect();
if words.is_empty() {
return None;
}
let kw = |i: usize| words.get(i).map(|w| w.to_uppercase()).unwrap_or_default();
if kw(0) == "DROP" && kw(1) == "TABLE" {
let mut i = 2;
let mut if_exists = false;
if kw(i) == "IF" && kw(i + 1) == "EXISTS" {
if_exists = true;
i += 2;
}
let (tables, after) = read_table_list(&words, i);
let cascade = words[after..].iter().any(|w| w.to_uppercase() == "CASCADE");
if tables.is_empty() {
return None;
}
return Some(Destructive::DropTable {
tables,
cascade,
if_exists,
});
}
if kw(0) == "TRUNCATE" {
let mut i = 1;
if kw(i) == "TABLE" {
i += 1;
}
if kw(i) == "ONLY" {
i += 1;
}
let (tables, after) = read_table_list(&words, i);
let cascade = words[after..].iter().any(|w| w.to_uppercase() == "CASCADE");
if tables.is_empty() {
return None;
}
return Some(Destructive::Truncate { tables, cascade });
}
if kw(0) == "DELETE" && kw(1) == "FROM" {
let mut i = 2;
if kw(i) == "ONLY" {
i += 1;
}
let table = clean_ident(words.get(i)?)?;
let has_where = words.iter().any(|w| w.to_uppercase() == "WHERE");
return Some(Destructive::DeleteFrom { table, has_where });
}
None
}
fn read_table_list(words: &[String], mut i: usize) -> (Vec<String>, usize) {
let mut tables = Vec::new();
while i < words.len() {
let w = &words[i];
let upper = w.to_uppercase();
if upper == "CASCADE" || upper == "RESTRICT" {
break;
}
let trailing_comma = w.ends_with(',');
if let Some(t) = clean_ident(w) {
tables.push(t);
}
i += 1;
if !trailing_comma && !words.get(i).map(|n| n == ",").unwrap_or(false) {
if words.get(i).map(|n| n.starts_with(',')).unwrap_or(false) {
continue;
}
break;
}
if words.get(i).map(|n| n == ",").unwrap_or(false) {
i += 1;
}
}
(tables, i)
}
fn clean_ident(raw: &str) -> Option<String> {
let t = raw.trim_matches(',').trim_matches('"').trim();
if t.is_empty() {
return None;
}
let ok = t
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '$');
if ok {
Some(t.to_string())
} else {
None
}
}
fn group_thousands(n: i64) -> String {
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenizer_respects_quotes() {
let t = shell_tokens(r#"psql -h prod -c "DROP TABLE users CASCADE""#);
assert_eq!(
t,
vec!["psql", "-h", "prod", "-c", "DROP TABLE users CASCADE"]
);
let t = shell_tokens("psql -c 'DELETE FROM orders'");
assert_eq!(t[2], "DELETE FROM orders");
}
#[test]
fn extracts_sql_from_variants() {
let t = shell_tokens("psql -U app --command 'TRUNCATE logs'");
assert_eq!(extract_sql(&t).unwrap(), "TRUNCATE logs");
let t = shell_tokens("psql --command='DROP TABLE a'");
assert_eq!(extract_sql(&t).unwrap(), "DROP TABLE a");
}
#[test]
fn parses_drop_variants() {
let d = parse_destructive("DROP TABLE users CASCADE");
assert_eq!(
d,
vec![Destructive::DropTable {
tables: vec!["users".into()],
cascade: true,
if_exists: false
}]
);
let d = parse_destructive("drop table if exists a, b");
assert_eq!(
d,
vec![Destructive::DropTable {
tables: vec!["a".into(), "b".into()],
cascade: false,
if_exists: true
}]
);
}
#[test]
fn parses_truncate_and_delete() {
assert_eq!(
parse_destructive("TRUNCATE TABLE audit_log"),
vec![Destructive::Truncate {
tables: vec!["audit_log".into()],
cascade: false
}]
);
assert_eq!(
parse_destructive("DELETE FROM users"),
vec![Destructive::DeleteFrom {
table: "users".into(),
has_where: false
}]
);
assert_eq!(
parse_destructive("DELETE FROM users WHERE id = 5"),
vec![Destructive::DeleteFrom {
table: "users".into(),
has_where: true
}]
);
}
#[test]
fn ignores_safe_sql_and_non_psql() {
assert!(parse_destructive("SELECT * FROM users").is_empty());
assert!(preview_for("git push origin main", std::path::Path::new("."), true).is_none());
assert!(preview_for("psql -c 'SELECT 1'", std::path::Path::new("."), true).is_none());
}
#[test]
fn connection_args_keeps_only_connection_parameters() {
let t = shell_tokens("psql -h db.prod -U app -d shop -c 'DROP TABLE x'");
assert_eq!(
connection_args(&t),
vec!["-h", "db.prod", "-U", "app", "-d", "shop"]
);
}
#[test]
fn file_flag_never_reaches_the_preview_invocation() {
let t = shell_tokens(r#"psql -d shop -f wipe.sql -c "DROP TABLE users""#);
assert_eq!(connection_args(&t), vec!["-d", "shop"]);
}
#[test]
fn side_effecting_flags_are_dropped_not_enumerated() {
let t = shell_tokens(
r#"psql -d shop -o /etc/passwd -L audit.log -W -f a.sql -c "TRUNCATE users""#,
);
assert_eq!(connection_args(&t), vec!["-d", "shop"]);
}
#[test]
fn attached_short_forms_are_parsed_not_matched() {
let t = shell_tokens(r#"psql -dshop "-cTRUNCATE users""#);
assert_eq!(connection_args(&t), vec!["-d", "shop"]);
}
#[test]
fn boolean_clusters_are_dropped_without_eating_the_dbname() {
let t = shell_tokens("psql -tAX -d shop -c 'TRUNCATE users'");
assert_eq!(connection_args(&t), vec!["-d", "shop"]);
}
#[test]
fn dropped_flags_consume_their_value() {
let t = shell_tokens("psql -f wipe.sql shop app -c 'TRUNCATE users'");
assert_eq!(connection_args(&t), vec!["-U", "app", "-d", "shop"]);
}
#[test]
fn long_forms_both_spellings() {
let t = shell_tokens("psql --host=db --port 6543 --username=app --dbname shop -c 'x'");
assert_eq!(
connection_args(&t),
vec!["-h", "db", "-p", "6543", "-U", "app", "-d", "shop"]
);
}
#[test]
fn psql_program_requires_the_exact_binary_name() {
assert_eq!(
psql_program(&shell_tokens("/usr/local/pgsql/bin/psql -c 'x'")),
Some("/usr/local/pgsql/bin/psql".to_string())
);
assert_eq!(psql_program(&shell_tokens("evilpsql -c 'x'")), None);
}
#[cfg(unix)]
use crate::testutil::TempTree;
fn truncate_of(sql: &str) -> (Vec<String>, bool) {
match parse_destructive(sql).into_iter().next() {
Some(Destructive::Truncate { tables, cascade }) => (tables, cascade),
other => panic!("expected a TRUNCATE, got {other:?} for {sql:?}"),
}
}
#[test]
fn a_table_list_ends_at_cascade_rather_than_swallowing_it() {
let (tables, cascade) = truncate_of("TRUNCATE users CASCADE");
assert_eq!(tables, ["users"]);
assert!(cascade);
let (tables, cascade) = truncate_of("TRUNCATE users RESTRICT");
assert_eq!(tables, ["users"]);
assert!(!cascade, "RESTRICT is the opposite of CASCADE");
}
#[test]
fn a_comma_separated_list_is_read_in_either_spelling() {
for sql in ["TRUNCATE a, b", "TRUNCATE a , b", "TRUNCATE TABLE a, b"] {
let (tables, _) = truncate_of(sql);
assert_eq!(tables, ["a", "b"], "{sql}");
}
let (tables, _) = truncate_of("TRUNCATE a, b,");
assert_eq!(tables, ["a", "b"]);
}
#[test]
fn a_comma_without_a_space_is_a_known_gap_with_no_insurance_beneath() {
assert!(
parse_destructive("TRUNCATE a,b").is_empty(),
"if this starts parsing, the gap closed and this test should be \
inverted rather than deleted"
);
assert!(parse_destructive("DROP TABLE a,b").is_empty());
assert_eq!(truncate_of("TRUNCATE a, b").0, ["a", "b"]);
}
#[test]
fn only_is_stepped_over_rather_than_taken_as_the_table() {
let (tables, _) = truncate_of("TRUNCATE ONLY users");
assert_eq!(tables, ["users"]);
match parse_destructive("DELETE FROM ONLY users")
.into_iter()
.next()
{
Some(Destructive::DeleteFrom { table, has_where }) => {
assert_eq!(table, "users");
assert!(!has_where);
}
other => panic!("expected a DELETE, got {other:?}"),
}
}
#[test]
fn delete_needs_both_of_its_keywords() {
assert!(parse_destructive("DELETE ONLY users").is_empty());
assert!(parse_destructive("FROM users").is_empty());
}
#[test]
fn a_malformed_if_exists_does_not_make_a_drop_invisible() {
let found = parse_destructive("DROP TABLE IF users");
assert!(
!found.is_empty(),
"a DROP with a broken IF EXISTS is still a DROP"
);
}
#[test]
fn an_identifier_may_be_schema_qualified_or_carry_the_allowed_symbols() {
for (sql, want) in [
("TRUNCATE public.users", "public.users"),
("TRUNCATE _private", "_private"),
("TRUNCATE tab$le", "tab$le"),
("TRUNCATE \"users\"", "users"),
] {
let (tables, _) = truncate_of(sql);
assert_eq!(tables, [want], "{sql}");
}
assert!(parse_destructive("TRUNCATE (select 1)").is_empty());
}
#[test]
fn the_tokenizer_keeps_each_quote_kind_to_itself() {
assert_eq!(
shell_tokens("psql -c 'it\"s fine'"),
["psql", "-c", "it\"s fine"]
);
assert_eq!(
shell_tokens("psql -c \"it's fine\""),
["psql", "-c", "it's fine"]
);
}
#[test]
fn a_backslash_escapes_only_inside_double_quotes() {
assert_eq!(
shell_tokens("psql -c \"a\\\"b\""),
["psql", "-c", "a\"b"],
"the escaped quote belongs to the string, not to its end"
);
assert_eq!(shell_tokens("psql a\\b"), ["psql", "a\\b"]);
}
#[test]
fn a_psql_invocation_without_a_statement_has_nothing_to_preview() {
assert!(preview_for("psql -d shop", std::path::Path::new("."), false).is_none());
assert!(
preview_for("psql -d shop -U app -tAX", std::path::Path::new("."), false).is_none()
);
}
#[test]
fn every_connection_parameter_survives_the_rebuild() {
let args = connection_args(&shell_tokens(
"psql -h db.internal -p 5433 -U app -d shop -c \"TRUNCATE users\"",
));
for pair in [
["-h", "db.internal"],
["-p", "5433"],
["-U", "app"],
["-d", "shop"],
] {
assert!(
args.windows(2).any(|w| w == pair),
"{pair:?} must survive: {args:?}"
);
}
assert!(!args.iter().any(|a| a.contains("TRUNCATE")), "{args:?}");
}
#[test]
fn a_lone_dash_is_a_positional_not_a_flag_cluster() {
let args = connection_args(&shell_tokens("psql -"));
assert!(args.windows(2).any(|w| w == ["-d", "-"]), "{args:?}");
}
#[test]
fn a_table_list_that_reaches_cascade_after_a_comma_still_stops() {
let (tables, cascade) = truncate_of("TRUNCATE a, CASCADE");
assert_eq!(tables, ["a"]);
assert!(cascade);
}
#[test]
fn two_tables_without_a_comma_are_not_a_list() {
let (tables, _) = truncate_of("TRUNCATE a b");
assert_eq!(tables, ["a"]);
}
#[test]
fn thousands_are_grouped_at_every_boundary() {
assert_eq!(group_thousands(0), "0");
assert_eq!(group_thousands(999), "999");
assert_eq!(group_thousands(1000), "1,000");
assert_eq!(group_thousands(100), "100");
assert_eq!(group_thousands(1234567), "1,234,567");
assert_eq!(group_thousands(-1000), "-1,000");
}
#[test]
fn a_row_count_that_was_never_analyzed_says_so() {
let unknown = TableInfo {
rows: -1,
dependents: vec![],
};
assert_eq!(unknown.rows_display(), "unknown (never analyzed)");
let counted = TableInfo {
rows: 0,
dependents: vec![],
};
assert_eq!(counted.rows_display(), "0");
}
#[cfg(unix)]
fn stub_psql(dir: &std::path::Path, body: &str, code: i32) -> std::path::PathBuf {
stub_psql_full(dir, body, "", code)
}
#[cfg(unix)]
fn stub_psql_full(
dir: &std::path::Path,
stdout: &str,
stderr: &str,
code: i32,
) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt as _;
let path = dir.join("psql");
std::fs::write(
&path,
format!(
"#!/bin/sh\nprintf '%s\\n' \"{stdout}\"\nprintf '%s\\n' \"{stderr}\" >&2\nexit {code}\n"
),
)
.expect("stub must be writable");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("stub must be executable");
path
}
#[cfg(unix)]
#[test]
fn introspection_reads_the_row_count_and_the_dependents() {
let tmp = TempTree::new("pg-introspect");
let psql = stub_psql(tmp.path(), "SET\n\n120000\norders,invoices\n", 0);
let command = format!("{} -d shop -c \"TRUNCATE users\"", psql.display());
assert_eq!(
fk_dependents(&command, "users"),
["orders", "invoices"],
"the dependents decide what a CASCADE would also empty"
);
let p = preview_for(&command, std::path::Path::new("."), true)
.expect("a truncate is previewable");
assert!(
p.lines.iter().any(|l| l.contains("120,000")),
"the row estimate is the blast radius: {:?}",
p.lines
);
assert!(
p.lines
.iter()
.any(|l| l.contains("without CASCADE") && l.contains("orders")),
"a truncate with dependents and no CASCADE will fail, and saying so \
is the difference between a preview and a guess: {:?}",
p.lines
);
assert!(
!p.lines
.iter()
.any(|l| l.contains("static analysis only") || l.contains("not consulted")),
"the database answered: {:?}",
p.lines
);
let with_cascade = format!("{} -d shop -c \"TRUNCATE users CASCADE\"", psql.display());
let p = preview_for(&with_cascade, std::path::Path::new("."), true)
.expect("a truncate is previewable");
assert!(
!p.lines.iter().any(|l| l.contains("without CASCADE")),
"CASCADE was given: {:?}",
p.lines
);
}
#[cfg(unix)]
#[test]
fn an_empty_dependent_list_is_not_a_dependent() {
let tmp = TempTree::new("pg-deps");
let psql = stub_psql(tmp.path(), "SET\n7\norders,,invoices\n", 0);
let command = format!("{} -d shop -c \"TRUNCATE users\"", psql.display());
assert_eq!(fk_dependents(&command, "users"), ["orders", "invoices"]);
}
#[cfg(unix)]
#[test]
fn a_database_that_refuses_degrades_to_static_analysis() {
let tmp = TempTree::new("pg-refused");
let psql = stub_psql_full(tmp.path(), "", "FATAL: no", 1);
let command = format!("{} -d shop -c \"TRUNCATE users\"", psql.display());
assert!(fk_dependents(&command, "users").is_empty());
let p = preview_for(&command, std::path::Path::new("."), true)
.expect("static analysis still applies");
assert!(
p.lines
.iter()
.any(|l| l.contains("psql exited 1: FATAL: no")),
"{:?}",
p.lines
);
assert!(
p.lines.iter().any(|l| l.contains("static analysis only")),
"{:?}",
p.lines
);
assert!(
!p.lines.iter().any(|l| l.contains("unreachable")),
"a non-zero exit is not a claim about reachability: {:?}",
p.lines
);
assert!(
p.lines.iter().any(|l| l.contains("TRUNCATE users")),
"{:?}",
p.lines
);
}
#[cfg(unix)]
#[test]
fn a_refused_password_is_reported_with_the_remedy() {
let tmp = TempTree::new("pg-nopw");
let psql = stub_psql_full(
tmp.path(),
"",
"psql: error: connection to server at localhost, port 5432 failed: fe_sendauth: no password supplied",
2,
);
let command = format!("{} -d shop -c \"TRUNCATE users\"", psql.display());
let p = preview_for(&command, std::path::Path::new("."), true)
.expect("static analysis still applies");
let footer = p
.lines
.iter()
.find(|l| l.contains("live introspection failed"))
.unwrap_or_else(|| panic!("no failure line: {:?}", p.lines));
assert!(footer.contains("psql exited 2"), "{footer}");
assert!(footer.contains("no password supplied"), "{footer}");
assert!(footer.contains("PGPASSWORD"), "{footer}");
}
#[cfg(unix)]
#[test]
fn an_answer_without_a_count_is_reported_as_unread() {
let tmp = TempTree::new("pg-lastonly");
let psql = stub_psql(tmp.path(), "orders,invoices\n", 0);
let command = format!("{} -d shop -c \"TRUNCATE users\"", psql.display());
let p = preview_for(&command, std::path::Path::new("."), true)
.expect("static analysis still applies");
let footer = p
.lines
.iter()
.find(|l| l.contains("live introspection failed"))
.unwrap_or_else(|| panic!("no failure line: {:?}", p.lines));
assert!(footer.contains("no row estimate was found"), "{footer}");
assert!(footer.contains("orders,invoices"), "{footer}");
}
#[cfg(unix)]
#[test]
fn the_catalog_query_is_sent_as_three_separate_statements() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = TempTree::new("pg-argv");
let argv_file = tmp.path().join("argv");
let psql = tmp.path().join("psql");
std::fs::write(
&psql,
format!(
"#!/bin/sh\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done > \"{}\"\nprintf 'SET\\n7\\n\\n'\nexit 0\n",
argv_file.display()
),
)
.expect("stub must be writable");
std::fs::set_permissions(&psql, std::fs::Permissions::from_mode(0o755))
.expect("stub must be executable");
let command = format!("{} -d shop -c \"TRUNCATE users\"", psql.display());
let p = preview_for(&command, std::path::Path::new("."), true)
.expect("a truncate is previewable");
assert!(
p.lines
.iter()
.any(|l| l.contains("rows to erase (estimate) : 7")),
"{:?}",
p.lines
);
let argv = std::fs::read_to_string(&argv_file).expect("the stub recorded its argv");
let argv: Vec<&str> = argv.lines().collect();
let statements: Vec<&str> = argv
.windows(2)
.filter(|w| w[0] == "-c")
.map(|w| w[1])
.collect();
assert_eq!(statements.len(), 3, "{argv:?}");
assert!(
statements[0].starts_with("SET default_transaction_read_only"),
"{argv:?}"
);
assert!(statements[1].contains("reltuples"), "{argv:?}");
assert!(statements[2].contains("pg_constraint"), "{argv:?}");
for s in &statements {
assert_eq!(
s.matches(';').count(),
1,
"one statement per flag, or psql < 15 prints only the last: {s}"
);
}
assert!(
argv.windows(2).any(|w| w == ["-v", "ON_ERROR_STOP=1"]),
"a failing middle statement must end the run non-zero: {argv:?}"
);
assert!(
argv.contains(&"-w"),
"the preview must never prompt: {argv:?}"
);
}
#[test]
fn a_denied_command_says_the_database_was_not_consulted() {
let p = preview_for(
"psql -d shop -c \"DROP TABLE users\"",
std::path::Path::new("."),
false,
)
.expect("static analysis still applies");
let footer = p.lines.last().expect("a footer");
assert!(footer.contains("not consulted"), "{footer}");
assert!(footer.contains("denied"), "{footer}");
assert!(!footer.contains("unreachable"), "{footer}");
}
#[test]
fn a_filtered_delete_says_the_database_was_not_consulted() {
let p = preview_for(
"/nonexistent/bin/psql -d shop -c \"DELETE FROM users WHERE id < 5\"",
std::path::Path::new("."),
true,
)
.expect("static analysis still applies");
assert!(
p.lines
.iter()
.any(|l| l.contains("cannot estimate cheaply")),
"{:?}",
p.lines
);
let footer = p.lines.last().expect("a footer");
assert!(footer.contains("not consulted"), "{footer}");
assert!(
footer.contains("no statement here has a cheap row estimate"),
"{footer}"
);
assert!(!footer.contains("unreachable"), "{footer}");
}
#[test]
fn a_psql_that_cannot_be_started_is_named() {
let p = preview_for(
"/nonexistent/bin/psql -d shop -c \"TRUNCATE users\"",
std::path::Path::new("."),
true,
)
.expect("static analysis still applies");
let footer = p
.lines
.iter()
.find(|l| l.contains("live introspection failed"))
.unwrap_or_else(|| panic!("no failure line: {:?}", p.lines));
assert!(
footer.contains("could not start /nonexistent/bin/psql"),
"{footer}"
);
assert!(
p.lines.iter().any(|l| l.contains("static analysis only")),
"{:?}",
p.lines
);
}
#[test]
fn a_client_that_is_not_psql_is_not_previewed() {
assert!(preview_for(
"mysql -e \"DROP TABLE users\"",
std::path::Path::new("."),
false
)
.is_none());
assert!(preview_for(
"psqlx -c \"DROP TABLE users\"",
std::path::Path::new("."),
false
)
.is_none());
assert!(preview_for(
"evilpsql -c \"DROP TABLE users\"",
std::path::Path::new("."),
false
)
.is_none());
assert!(preview_for(
"/usr/bin/psql -c \"DROP TABLE users\"",
std::path::Path::new("."),
false
)
.is_some());
assert!(preview_for(
"psql.exe -c \"DROP TABLE users\"",
std::path::Path::new("."),
false
)
.is_some());
assert!(preview_for(
r#"C:\pg\16\bin\psql.exe -c "DROP TABLE users""#,
std::path::Path::new("."),
false
)
.is_some());
}
}