use std::fmt;
use autumn_web::config::DatabaseBackend;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum RiskLevel {
Safe,
PotentiallyBlocking,
Destructive,
Irreversible,
DataBackfill,
ManualReview,
Unsupported,
}
impl fmt::Display for RiskLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Safe => write!(f, "safe"),
Self::PotentiallyBlocking => write!(f, "potentially-blocking"),
Self::Destructive => write!(f, "destructive"),
Self::Irreversible => write!(f, "irreversible"),
Self::DataBackfill => write!(f, "data-backfill"),
Self::ManualReview => write!(f, "manual-review"),
Self::Unsupported => write!(f, "unsupported"),
}
}
}
#[derive(Debug, Clone)]
pub struct SafetyFinding {
pub operation: String,
pub risk: RiskLevel,
pub why: &'static str,
pub next_action: &'static str,
}
#[cfg(test)]
pub fn classify_sql(sql: &str) -> Vec<SafetyFinding> {
classify_sql_for(DatabaseBackend::Postgres, sql)
}
pub fn classify_sql_for(backend: DatabaseBackend, sql: &str) -> Vec<SafetyFinding> {
let stmts = statements(backend, sql);
let newly_created: Vec<String> = stmts
.iter()
.filter(|s| !has_review_suppression(s))
.filter_map(|s| extract_created_table_name(&normalize_statement(backend, s)))
.collect();
stmts
.iter()
.filter(|stmt| !has_review_suppression(stmt))
.flat_map(|stmt| {
let normalized = normalize_statement(backend, stmt);
let mut findings = classify_statement(backend, &normalized, &newly_created);
findings.retain(|f| {
(f.operation != CREATE_INDEX_PG_OP && f.operation != CREATE_INDEX_SQLITE_OP)
|| extract_index_table_name(&normalized)
.is_none_or(|t| !newly_created.iter().any(|c| same_table(backend, c, t)))
});
findings
})
.collect()
}
fn has_review_suppression(stmt: &str) -> bool {
stmt.lines().any(|line| {
let trimmed = line.trim();
trimmed.starts_with("--") && trimmed.contains("autumn-safety: reviewed")
})
}
pub fn is_safe(findings: &[SafetyFinding]) -> bool {
findings.iter().all(|f| f.risk == RiskLevel::Safe)
}
pub fn has_unsafe_findings(findings: &[SafetyFinding]) -> bool {
findings.iter().any(|f| f.risk > RiskLevel::Safe)
}
pub fn has_executable_sql(backend: DatabaseBackend, sql: &str) -> bool {
statements(backend, sql).iter().any(|stmt| {
stmt.lines()
.any(|line| !line.trim().is_empty() && !line.trim().starts_with("--"))
})
}
fn same_table(backend: DatabaseBackend, a: &str, b: &str) -> bool {
match backend {
DatabaseBackend::Postgres => a == b,
DatabaseBackend::Sqlite => a.eq_ignore_ascii_case(b),
}
}
fn extract_created_table_name(normalized: &str) -> Option<String> {
let rest = normalized.strip_prefix("create table ")?;
if rest.starts_with("if not exists ") {
return None;
}
let name = rest.split([' ', '(']).next()?;
if name.is_empty() {
None
} else {
Some(name.to_owned())
}
}
fn extract_index_table_name(normalized: &str) -> Option<&str> {
let after_on = normalized.find(" on ").map(|i| &normalized[i + 4..])?;
let name = after_on.split([' ', '(']).next()?;
if name.is_empty() { None } else { Some(name) }
}
fn extract_altered_table_name(normalized: &str) -> Option<&str> {
let rest = normalized.strip_prefix("alter table ")?;
let rest = rest.strip_prefix("if exists ").unwrap_or(rest);
let rest = rest.strip_prefix("only ").unwrap_or(rest);
let name = rest.split([' ', '(']).next()?;
if name.is_empty() { None } else { Some(name) }
}
fn statements(backend: DatabaseBackend, sql: &str) -> Vec<String> {
split_statements(backend, &strip_block_comments(backend, sql))
}
pub fn split_statements(backend: DatabaseBackend, sql: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current = String::new();
for (kind, text) in tokens(backend, sql) {
if kind == TokenKind::Plain && text == ";" {
let trimmed = current.trim();
if !trimmed.is_empty() {
statements.push(trimmed.to_owned());
}
current.clear();
} else {
current.push_str(text);
}
}
let trimmed = current.trim();
if !trimmed.is_empty() {
statements.push(trimmed.to_owned());
}
statements
}
fn alter_table_subcommands(backend: DatabaseBackend, normalized: &str) -> Vec<&str> {
let mut rest = normalized.strip_prefix("alter table ").unwrap_or("");
if backend == DatabaseBackend::Postgres {
for keyword in ["if exists ", "only "] {
rest = rest.strip_prefix(keyword).unwrap_or(rest);
}
}
let name_len: usize = tokens(backend, rest)
.take_while(|&(kind, text)| !(kind == TokenKind::Plain && text == " "))
.map(|(_, text)| text.len())
.sum();
let subcommands = rest[name_len..].trim_start();
let mut result = Vec::new();
let mut depth: i32 = 0;
let mut start = 0;
let mut i = 0;
for (kind, text) in tokens(backend, subcommands) {
if kind == TokenKind::Plain {
match text {
"(" => depth += 1,
")" => depth -= 1,
"," if depth == 0 => {
result.push(subcommands[start..i].trim());
start = i + 1;
}
_ => {}
}
}
i += text.len();
}
let last = subcommands[start..].trim();
if !last.is_empty() {
result.push(last);
}
result
}
fn is_known_alter_subcommand(backend: DatabaseBackend, subcommand: &str) -> bool {
if backend == DatabaseBackend::Sqlite {
return true;
}
let add_col_known =
subcommand.starts_with("add column ") && !subcommand.contains(" primary key");
add_col_known
|| subcommand.starts_with("drop column ")
|| subcommand.starts_with("rename column ") || subcommand.starts_with("rename to ") || (subcommand.starts_with("alter column ") && subcommand.contains(" type "))
}
fn canonical_sqlite_alter(normalized: &str) -> String {
if !normalized.starts_with("alter table ") {
return normalized.to_owned();
}
let subcommands = alter_table_subcommands(DatabaseBackend::Sqlite, normalized);
if subcommands.is_empty() {
return normalized.to_owned();
}
let head_len = subcommands[0].as_ptr() as usize - normalized.as_ptr() as usize;
let head = &normalized[..head_len];
let rewritten: Vec<String> = subcommands
.iter()
.map(|sub| {
if let Some(rest) = sub.strip_prefix("rename ")
&& !rest.starts_with("column ")
&& !rest.starts_with("to ")
&& rest.contains(" to ")
{
return format!("rename column {rest}");
}
for verb in ["add", "drop"] {
let prefix = format!("{verb} ");
if let Some(rest) = sub.strip_prefix(&prefix)
&& !rest.starts_with("column ")
&& !rest.starts_with("constraint ")
{
return format!("{verb} column {rest}");
}
}
(*sub).to_owned()
})
.collect();
format!("{head}{}", rewritten.join(", "))
}
fn is_sqlite_alter_subcommand(subcommand: &str) -> bool {
["rename to ", "rename column ", "add column ", "drop column "]
.iter()
.any(|p| subcommand.starts_with(p))
|| (subcommand.starts_with("rename ") && subcommand.contains(" to "))
|| (subcommand.starts_with("add ") && !subcommand.starts_with("add constraint "))
|| (subcommand.starts_with("drop ") && !subcommand.starts_with("drop constraint "))
}
const CREATE_INDEX_PG_OP: &str = "CREATE INDEX (non-concurrent)";
const CREATE_INDEX_SQLITE_OP: &str = "CREATE INDEX";
fn add_column_default_token(subcommand: &str) -> Option<String> {
let mut from = 0;
let rest = loop {
let at = subcommand[from..].find(" default")? + from;
let after = &subcommand[at + " default".len()..];
let value = match after.strip_prefix(' ') {
Some(v) => Some(v),
None if after.starts_with('(') => Some(after),
None => None,
};
if let Some(value) = value
&& !subcommand[..at].trim_end().ends_with(" set")
{
break value.trim_start();
}
from = at + " default".len();
};
if rest.starts_with('(') {
let mut depth = 0usize;
for (i, c) in rest.char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(rest[..=i].to_owned());
}
}
_ => {}
}
}
return Some(rest.to_owned());
}
if rest.starts_with('\'') {
let bytes = rest.as_bytes();
let mut i = 1;
while i < bytes.len() {
if bytes[i] == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
i += 2; continue;
}
return Some(rest[..=i].to_owned());
}
i += 1;
}
return Some(rest.to_owned());
}
Some(
rest.split_whitespace()
.next()
.unwrap_or_default()
.trim_end_matches(',')
.to_owned(),
)
}
fn is_sqlite_constant_default(default: &str) -> bool {
let inner = default
.strip_prefix('(')
.and_then(|d| d.strip_suffix(')'))
.unwrap_or(default)
.trim();
if matches!(inner, "current_time" | "current_date" | "current_timestamp") {
return false;
}
if inner.starts_with('\'') || inner.starts_with("x'") {
return true;
}
if is_numeric_literal(inner) {
return true;
}
let unsigned = inner.trim_start_matches(['+', '-']).trim_start();
!unsigned.contains(['(', ')', '+', '-', '*', '/', '|', '<', '>', '=', '\''])
}
fn is_numeric_literal(value: &str) -> bool {
let body = value.strip_prefix(['+', '-']).unwrap_or(value);
if let Some(hex) = body.strip_prefix("0x").or_else(|| body.strip_prefix("0X")) {
return !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit());
}
let (mantissa, exponent) = body
.split_once(['e', 'E'])
.map_or((body, None), |(m, e)| (m, Some(e)));
if !is_decimal(mantissa) {
return false;
}
exponent.is_none_or(|e| {
let digits = e.strip_prefix(['+', '-']).unwrap_or(e);
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
})
}
fn is_decimal(value: &str) -> bool {
let (int, frac) = value
.split_once('.')
.map_or((value, None), |(i, f)| (i, Some(f)));
if int.is_empty() && frac.is_none_or(str::is_empty) {
return false;
}
int.chars().all(|c| c.is_ascii_digit())
&& frac.is_none_or(|f| f.chars().all(|c| c.is_ascii_digit()))
}
fn sqlite_unsupported_statement(normalized: &str) -> Option<SafetyFinding> {
let (operation, next_action) = if normalized.starts_with("truncate ") {
(
"TRUNCATE (unsupported on SQLite)",
"SQLite has no TRUNCATE statement. Use `DELETE FROM <table>;` (add `DELETE FROM \
sqlite_sequence WHERE name = '<table>';` to reset AUTOINCREMENT).",
)
} else if normalized.starts_with("create index concurrently ")
|| normalized.starts_with("create unique index concurrently ")
{
(
"CREATE INDEX CONCURRENTLY (unsupported on SQLite)",
"SQLite has no CONCURRENTLY option. Drop the keyword and build the index during a \
low-traffic window.",
)
} else if normalized.starts_with("drop index concurrently ") {
(
"DROP INDEX CONCURRENTLY (unsupported on SQLite)",
"SQLite has no CONCURRENTLY option. Drop the keyword — a plain DROP INDEX is a cheap \
catalog edit on SQLite.",
)
} else if normalized.starts_with("create sequence")
|| normalized.starts_with("alter sequence")
|| normalized.starts_with("drop sequence")
{
(
"SEQUENCE statement (unsupported on SQLite)",
"SQLite has no sequences. Use an INTEGER PRIMARY KEY AUTOINCREMENT column instead.",
)
} else if normalized.starts_with("create type")
|| normalized.starts_with("alter type")
|| normalized.starts_with("drop type")
{
(
"TYPE statement (unsupported on SQLite)",
"SQLite has no user-defined types. Model an enum as a TEXT column with a CHECK \
constraint.",
)
} else if normalized.starts_with("create extension") || normalized.starts_with("drop extension")
{
(
"EXTENSION statement (unsupported on SQLite)",
"SQLite has no extensions in this sense. Remove the statement, or gate the migration to \
the Postgres backend.",
)
} else if normalized.starts_with("comment on") {
(
"COMMENT ON (unsupported on SQLite)",
"SQLite has no COMMENT ON. Remove the statement — it carries no schema meaning.",
)
} else if normalized.starts_with("create materialized view")
|| normalized.starts_with("drop materialized view")
|| normalized.starts_with("refresh materialized view")
{
(
"MATERIALIZED VIEW statement (unsupported on SQLite)",
"SQLite has no materialized views. Use a plain view, or a real table refreshed by a \
background job.",
)
} else if normalized.starts_with("grant ") || normalized.starts_with("revoke ") {
(
"GRANT/REVOKE (unsupported on SQLite)",
"SQLite has no roles or grants — file permissions are the access control. Remove the \
statement.",
)
} else {
return sqlite_unsupported_clause(normalized);
};
Some(SafetyFinding {
operation: operation.to_owned(),
risk: RiskLevel::Unsupported,
why: "SQLite has no syntax for this statement, so the migration fails at apply time.",
next_action,
})
}
fn sqlite_unsupported_clause(normalized: &str) -> Option<SafetyFinding> {
let (operation, next_action) = if normalized.starts_with("merge into ") {
(
"MERGE (unsupported on SQLite)",
"SQLite has no MERGE. Use INSERT … ON CONFLICT (upsert), naming the conflicting \
columns rather than a constraint.",
)
} else if normalized.starts_with("drop index ")
&& normalized
.trim_start_matches("drop index ")
.trim_start_matches("if exists ")
.starts_with("sqlite_autoindex")
{
(
"DROP INDEX on an implicit index (unsupported on SQLite)",
"SQLite refuses to drop the index behind a UNIQUE or PRIMARY KEY constraint, even \
with IF EXISTS. Rebuild the table without the constraint instead.",
)
} else if is_create_index_statement(normalized) && has_postgres_index_clause(normalized) {
(
"CREATE INDEX with a Postgres-only clause (unsupported on SQLite)",
"SQLite's CREATE INDEX takes no USING, INCLUDE, WITH, TABLESPACE or NULLS NOT \
DISTINCT clause. Drop it — SQLite indexes are always B-trees. WHERE (partial), \
COLLATE and DESC are supported.",
)
} else if normalized.starts_with("create table") && has_postgres_table_clause(normalized) {
(
"CREATE TABLE with a Postgres-only clause (unsupported on SQLite)",
"SQLite has no declarative partitioning, identity columns, table inheritance or \
tablespaces. Use INTEGER PRIMARY KEY AUTOINCREMENT for an identity column; model \
partitioning in the application.",
)
} else if has_row_locking_clause(normalized) {
(
"SELECT … FOR UPDATE (unsupported on SQLite)",
"SQLite has no row-level locking clause — a write transaction locks the whole \
database. Drop the clause.",
)
} else if blank_quoted_spans(DatabaseBackend::Sqlite, normalized)
.contains("on conflict on constraint ")
{
(
"ON CONFLICT ON CONSTRAINT (unsupported on SQLite)",
"SQLite's upsert target is a column list, not a constraint name. Write \
`ON CONFLICT (<columns>)`.",
)
} else if normalized.starts_with("with ") && contains_data_modifying_cte(normalized) {
(
"Data-modifying CTE (unsupported on SQLite)",
"SQLite allows only SELECT inside a CTE. Run the UPDATE/DELETE/INSERT as its own \
statement.",
)
} else {
return None;
};
Some(SafetyFinding {
operation: operation.to_owned(),
risk: RiskLevel::Unsupported,
why: "SQLite has no syntax for this statement, so the migration fails at apply time.",
next_action,
})
}
fn has_row_locking_clause(normalized: &str) -> bool {
let bare = blank_quoted_spans(DatabaseBackend::Sqlite, normalized);
bare.contains(" for update") || bare.contains(" for share")
}
fn is_create_index_statement(normalized: &str) -> bool {
normalized.starts_with("create index") || normalized.starts_with("create unique index")
}
fn has_postgres_index_clause(normalized: &str) -> bool {
let sql = blank_quoted_spans(DatabaseBackend::Sqlite, normalized);
sql.contains(" using ")
|| sql.contains(" include (")
|| sql.contains(" with (")
|| sql.contains(" tablespace ")
|| sql.contains(" nulls not distinct")
}
fn has_postgres_table_clause(normalized: &str) -> bool {
let sql = blank_quoted_spans(DatabaseBackend::Sqlite, normalized);
sql.contains(" partition by ")
|| sql.contains(" partition of ")
|| sql.contains(" as identity")
|| sql.contains(" inherits (")
|| sql.contains(" tablespace ")
}
fn contains_data_modifying_cte(normalized: &str) -> bool {
let sql = blank_quoted_spans(DatabaseBackend::Sqlite, normalized).replace("( ", "(");
["(update ", "(delete ", "(insert into "]
.iter()
.any(|p| sql.contains(p))
}
fn blank_quoted_spans(backend: DatabaseBackend, sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
for (kind, text) in tokens(backend, sql) {
if kind == TokenKind::Quoted {
let last = text.chars().count() - 1;
out.extend(
text.chars()
.enumerate()
.map(|(i, c)| if i == 0 || i == last { c } else { ' ' }),
);
} else {
out.push_str(text);
}
}
out
}
fn has_inline_unique_constraint(subcommand: &str) -> bool {
subcommand.contains(" unique ") || subcommand.ends_with(" unique")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TokenKind {
Plain,
LineComment,
BlockComment,
Quoted,
}
fn tokens(backend: DatabaseBackend, sql: &str) -> impl Iterator<Item = (TokenKind, &str)> {
let mut rest = sql;
let mut prev = None;
std::iter::from_fn(move || {
let (kind, len) = token_at(backend, rest, prev)?;
let (text, tail) = rest.split_at(len);
rest = tail;
prev = text.chars().next_back();
Some((kind, text))
})
}
fn token_at(
backend: DatabaseBackend,
rest: &str,
prev: Option<char>,
) -> Option<(TokenKind, usize)> {
let first = rest.chars().next()?;
if let Some(len) = line_comment_len(backend, rest) {
return Some((TokenKind::LineComment, len));
}
if let Some(len) = block_comment_len(backend, rest) {
return Some((TokenKind::BlockComment, len));
}
if let Some(len) = quoted_len(backend, rest, prev) {
return Some((TokenKind::Quoted, len));
}
if backend == DatabaseBackend::Sqlite
&& prev.is_none_or(|c| !is_identifier_char(c))
&& let Some(len) = sqlite_variable_len(rest)
{
return Some((TokenKind::Plain, len));
}
Some((TokenKind::Plain, first.len_utf8()))
}
fn quoted_len(backend: DatabaseBackend, rest: &str, prev: Option<char>) -> Option<usize> {
let postgres = backend == DatabaseBackend::Postgres;
let word_start = prev.is_none_or(|c| !is_identifier_char(c));
match rest.as_bytes().first()? {
b'\'' => Some(doubled_quote_len(rest, '\'')),
b'"' => Some(doubled_quote_len(rest, '"')),
b'`' if !postgres => Some(doubled_quote_len(rest, '`')),
b'[' if !postgres => Some(rest[1..].find(']').map_or(rest.len(), |p| p + 2)),
b'e' | b'E' if postgres && word_start && rest[1..].starts_with('\'') => {
Some(1 + escape_string_len(&rest[1..]))
}
b'$' if postgres && word_start => dollar_quote_len(rest),
_ => None,
}
}
const fn is_identifier_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '$') || !c.is_ascii()
}
fn doubled_quote_len(rest: &str, close: char) -> usize {
let mut j = 1;
while let Some(rel) = rest[j..].find(close) {
let abs = j + rel;
if rest[abs + 1..].starts_with(close) {
j = abs + 2;
} else {
return abs + 1;
}
}
rest.len()
}
fn escape_string_len(rest: &str) -> usize {
let mut chars = rest.char_indices().skip(1);
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
chars.next();
}
'\'' if rest[i + 1..].starts_with('\'') => {
chars.next();
}
'\'' => return i + 1,
_ => {}
}
}
rest.len()
}
fn dollar_quote_len(rest: &str) -> Option<usize> {
let after = rest.strip_prefix('$')?;
let tag_body = &after[..after.find('$')?];
if tag_body.starts_with(|c: char| c.is_ascii_digit())
|| !tag_body.chars().all(|c| is_identifier_char(c) && c != '$')
{
return None;
}
let tag_len = tag_body.len() + 2;
let tag = &rest[..tag_len];
Some(
rest[tag_len..]
.find(tag)
.map_or(rest.len(), |p| p + 2 * tag_len),
)
}
fn line_comment_len(backend: DatabaseBackend, rest: &str) -> Option<usize> {
let end: &[char] = match backend {
DatabaseBackend::Postgres => &['\n', '\r'],
DatabaseBackend::Sqlite => &['\n'],
};
rest.starts_with("--")
.then(|| rest.find(end).unwrap_or(rest.len()))
}
fn block_comment_len(backend: DatabaseBackend, rest: &str) -> Option<usize> {
if !rest.starts_with("/*") {
return None;
}
let nests = backend == DatabaseBackend::Postgres;
let mut depth = 0usize;
let mut i = 0;
while i < rest.len() {
if rest[i..].starts_with("/*") && (nests || depth == 0) {
depth += 1;
i += 2;
} else if rest[i..].starts_with("*/") {
depth -= 1;
i += 2;
if depth == 0 {
return Some(i);
}
} else {
i += rest[i..].chars().next().map_or(1, char::len_utf8);
}
}
Some(rest.len())
}
fn sqlite_variable_len(rest: &str) -> Option<usize> {
let body = rest.strip_prefix(['$', '@', ':', '#'])?;
let name_len: usize = body
.chars()
.take_while(|&c| is_identifier_char(c))
.map(char::len_utf8)
.sum();
if name_len == 0 {
return None;
}
let after = &body[name_len..];
let args_len = if after.starts_with('(') {
after
.find(|c: char| c == ')' || c.is_whitespace())
.map_or(after.len(), |p| {
p + usize::from(after[p..].starts_with(')'))
})
} else {
0
};
Some(1 + name_len + args_len)
}
pub fn strip_block_comments(backend: DatabaseBackend, sql: &str) -> String {
let mut result = String::with_capacity(sql.len());
for (kind, text) in tokens(backend, sql) {
if kind == TokenKind::BlockComment {
result.push(' '); } else {
result.push_str(text);
}
}
result
}
fn strip_line_comments(backend: DatabaseBackend, sql: &str) -> String {
tokens(backend, sql)
.filter(|(kind, _)| *kind != TokenKind::LineComment)
.map(|(_, text)| text)
.collect()
}
pub fn normalize_statement(backend: DatabaseBackend, stmt: &str) -> String {
let stripped = strip_line_comments(backend, &strip_block_comments(backend, stmt));
let mut out = String::with_capacity(stripped.len());
for (kind, text) in tokens(backend, &stripped) {
if kind == TokenKind::Quoted {
out.push_str(text);
continue;
}
for c in text.chars() {
if !c.is_whitespace() {
out.extend(c.to_lowercase());
} else if !out.is_empty() && !out.ends_with(' ') {
out.push(' ');
}
}
}
out.truncate(out.trim_end().len());
out
}
pub fn contains_concurrent_index(sql: &str) -> bool {
let pg = DatabaseBackend::Postgres;
statements(pg, sql).iter().any(|stmt| {
let normalized = normalize_statement(pg, stmt);
normalized.contains("create index concurrently ")
|| normalized.contains("create unique index concurrently ")
|| normalized.starts_with("drop index concurrently ")
})
}
const STABLE_FN_PREFIXES: &[&str] = &["now(", "current_timestamp(", "localtimestamp("];
fn is_volatile_function_default(default_expr: &str) -> bool {
let Some(paren_pos) = default_expr.find('(') else {
return false; };
let is_fn_call = default_expr[..paren_pos]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
if !is_fn_call {
return false; }
!STABLE_FN_PREFIXES
.iter()
.any(|p| default_expr.starts_with(p))
}
#[allow(clippy::too_many_lines)]
fn classify_statement(
backend: DatabaseBackend,
normalized: &str,
newly_created: &[String],
) -> Vec<SafetyFinding> {
if normalized.is_empty() {
return vec![];
}
let sqlite = backend == DatabaseBackend::Sqlite;
let canonical;
let normalized = if sqlite {
canonical = canonical_sqlite_alter(normalized);
canonical.as_str()
} else {
normalized
};
if sqlite && let Some(f) = sqlite_unsupported_statement(normalized) {
return vec![f];
}
let mut findings = Vec::new();
let fresh_table = sqlite
&& extract_altered_table_name(normalized)
.is_some_and(|t| newly_created.iter().any(|c| same_table(backend, c, t)));
if normalized.starts_with("drop table") {
findings.push(SafetyFinding {
operation: "DROP TABLE".to_owned(),
risk: RiskLevel::Destructive,
why: "Drops the entire table and all its data. Old replicas that reference this \
table will error immediately.",
next_action: "Use expand/contract: first deploy code that stops using the table, \
then drop it in a subsequent release.",
});
return findings;
}
if normalized.starts_with("drop view") {
findings.push(SafetyFinding {
operation: "DROP VIEW".to_owned(),
risk: RiskLevel::Destructive,
why: "Drops the view. Old replicas that query this view will error immediately \
during a rolling deploy.",
next_action: "Use expand/contract: first deploy code that no longer references the \
view, then drop it in a subsequent release.",
});
return findings;
}
if normalized.starts_with("drop sequence") {
findings.push(SafetyFinding {
operation: "DROP SEQUENCE".to_owned(),
risk: RiskLevel::Destructive,
why: "Dropping a sequence breaks any column that uses it as a default \
(`nextval(seq)`) and any application code that calls `nextval` directly. \
Old replicas will error immediately on INSERT.",
next_action: "Use expand/contract: first deploy code that no longer relies on this \
sequence, then drop it in a subsequent release.",
});
return findings;
}
if normalized.starts_with("truncate ") {
findings.push(SafetyFinding {
operation: "TRUNCATE".to_owned(),
risk: RiskLevel::Destructive,
why: "Truncating a table deletes all data and acquires an AccessExclusiveLock, \
blocking all concurrent reads and writes.",
next_action: "If you need to empty the table, delete rows in small batches, or perform \
the truncate during a coordinated maintenance window.",
});
return findings;
}
if normalized.contains(" drop column ") {
findings.push(SafetyFinding {
operation: "DROP COLUMN".to_owned(),
risk: RiskLevel::Destructive,
why: if sqlite {
"Removes a column and its data. SQLite also refuses the statement outright in \
several cases. The column must not be a primary key, UNIQUE, or named by any \
index — a partial index's WHERE clause included. It must not appear in a CHECK \
constraint, a generated column, a foreign key, a view or a trigger."
} else {
"Removes a column and its data. Old replicas that SELECT or INSERT this column \
will error until they restart."
},
next_action: if sqlite {
"DROP INDEX every index that names this column earlier in the same migration. \
For a primary-key, UNIQUE, CHECK or generated-column reference, rebuild the \
table instead (`autumn schema diff --write-migration` emits the rebuild)."
} else {
"Use expand/contract: first deploy code that no longer reads or writes \
this column, then drop it in the next release."
},
});
}
if normalized.contains(" rename column ") {
findings.push(SafetyFinding {
operation: "RENAME COLUMN".to_owned(),
risk: RiskLevel::Irreversible,
why: "Renaming a column breaks queries from old replicas that still reference the \
old name, causing errors during a rolling deploy.",
next_action: "Use expand/contract: add the new column, dual-write, backfill existing \
rows, update all code, then drop the old column.",
});
}
if normalized.contains("alter table")
&& normalized.contains(" rename to ")
&& !normalized.contains(" rename column ")
{
findings.push(SafetyFinding {
operation: "RENAME TABLE".to_owned(),
risk: RiskLevel::Irreversible,
why: "Renaming a table breaks all queries from old replicas that reference the \
original name.",
next_action: "Create a view under the old name while the new name rolls out, or \
coordinate a maintenance window.",
});
}
if sqlite && normalized.starts_with("alter table") {
let subcommands = alter_table_subcommands(backend, normalized);
if subcommands.len() > 1 {
findings.push(SafetyFinding {
operation: "Multi-action ALTER TABLE (unsupported on SQLite)".to_owned(),
risk: RiskLevel::Unsupported,
why: "SQLite takes one action per ALTER TABLE. A comma-separated list is a parse \
error, so this statement fails at apply time.",
next_action: "Split the statement: one ALTER TABLE per action.",
});
}
for subcommand in subcommands {
if is_sqlite_alter_subcommand(subcommand) {
continue;
}
let is_alter_column = subcommand.starts_with("alter column ");
findings.push(SafetyFinding {
operation: if is_alter_column {
"ALTER COLUMN (unsupported on SQLite)".to_owned()
} else {
"ALTER TABLE subcommand (unsupported on SQLite)".to_owned()
},
risk: RiskLevel::Unsupported,
why: "SQLite's ALTER TABLE supports only RENAME, ADD COLUMN and DROP COLUMN. \
Any other subcommand is a parse error, so this statement fails at apply \
time.",
next_action: "Rebuild the table: create the new-shape table, copy the rows, drop \
the old table, rename the new one. `autumn schema diff \
--write-migration` emits that rebuild for you.",
});
break; }
}
if !sqlite
&& let Some(i) = normalized.find("alter column")
&& normalized[i..].contains(" type ")
{
findings.push(SafetyFinding {
operation: "ALTER COLUMN TYPE".to_owned(),
risk: RiskLevel::Destructive,
why: "Changing a column's type rewrites the column data and may be incompatible \
with values read by old replicas or application code.",
next_action: "Add a new column with the target type, migrate data, update code to \
use the new column, then drop the old one.",
});
}
if normalized.starts_with("alter table") {
for subcommand in alter_table_subcommands(backend, normalized) {
if subcommand.starts_with("add column ") && subcommand.contains("not null") {
let default_token = add_column_default_token(subcommand);
let has_default = if sqlite {
default_token.as_deref().is_some_and(|d| d != "null")
} else {
subcommand.contains(" default ")
};
let has_volatile_default = has_default
&& subcommand.find(" default ").is_some_and(|pos| {
let default_expr = subcommand[pos + " default ".len()..].trim();
is_volatile_function_default(default_expr)
});
if !has_default {
findings.push(SafetyFinding {
operation: "ADD COLUMN NOT NULL (no default)".to_owned(),
risk: if sqlite && !fresh_table {
RiskLevel::Unsupported
} else if sqlite {
RiskLevel::Safe
} else {
RiskLevel::PotentiallyBlocking
},
why: if sqlite {
"SQLite rejects ALTER TABLE … ADD COLUMN … NOT NULL without a \
DEFAULT outright — the statement fails at apply time."
} else {
"Adding a NOT NULL column without a DEFAULT forces Postgres to \
validate every existing row under an exclusive lock. On a large \
table this may time out."
},
next_action: if sqlite {
"Give the column a constant DEFAULT, or add it as nullable and \
backfill. SQLite cannot add the NOT NULL constraint afterwards \
(no ALTER COLUMN) — that needs a table rebuild."
} else {
"Provide a constant DEFAULT value, or add the column as \
nullable first, backfill existing rows, then add the NOT \
NULL constraint in a later migration."
},
});
break; } else if !sqlite && has_volatile_default {
findings.push(SafetyFinding {
operation: "ADD COLUMN NOT NULL (volatile default)".to_owned(),
risk: RiskLevel::PotentiallyBlocking,
why: "A volatile function-call DEFAULT (e.g. random(), gen_random_uuid()) \
is evaluated per-row: Postgres cannot use the PG11+ fast-constant \
path and must rewrite the entire table under an exclusive lock.",
next_action: "Use a constant literal DEFAULT instead (e.g. DEFAULT 0, \
DEFAULT ''), or add the column nullable, backfill, then \
add the NOT NULL constraint in a later migration.",
});
break; }
}
}
}
if normalized.starts_with("alter table") {
for subcommand in alter_table_subcommands(backend, normalized) {
if !subcommand.starts_with("add column ") {
continue;
}
if has_inline_unique_constraint(&blank_quoted_spans(backend, subcommand)) {
findings.push(if sqlite {
SafetyFinding {
operation: "ADD COLUMN UNIQUE (unsupported on SQLite)".to_owned(),
risk: RiskLevel::Unsupported,
why: "SQLite rejects ALTER TABLE … ADD COLUMN with an inline UNIQUE \
constraint — the statement fails at apply time.",
next_action: "Add the column without UNIQUE, then CREATE UNIQUE INDEX on \
it in the same migration.",
}
} else {
SafetyFinding {
operation: "ADD COLUMN UNIQUE (inline constraint)".to_owned(),
risk: RiskLevel::PotentiallyBlocking,
why: "An inline UNIQUE constraint implicitly builds a non-concurrent \
unique index under an exclusive table lock, blocking all reads and \
writes during the build.",
next_action: "Add the column without UNIQUE first, then create the unique \
index in a separate migration using \
`CREATE UNIQUE INDEX CONCURRENTLY`.",
}
});
}
if sqlite && subcommand.contains(" primary key") {
findings.push(SafetyFinding {
operation: "ADD COLUMN PRIMARY KEY (unsupported on SQLite)".to_owned(),
risk: RiskLevel::Unsupported,
why: "SQLite rejects ALTER TABLE … ADD COLUMN with an inline PRIMARY KEY \
constraint — the statement fails at apply time.",
next_action: "Rebuild the table with the new primary key \
(`autumn schema diff --write-migration` emits the rebuild).",
});
}
if sqlite {
if !fresh_table && subcommand.contains(" stored") && subcommand.contains(" as (") {
findings.push(SafetyFinding {
operation: "ADD COLUMN GENERATED … STORED (unsupported on SQLite)"
.to_owned(),
risk: RiskLevel::Unsupported,
why: "SQLite cannot add a STORED generated column to an existing table \
— the statement fails at apply time. VIRTUAL is accepted.",
next_action: "Use a VIRTUAL generated column, or rebuild the table with \
the STORED column in its CREATE TABLE.",
});
}
if !fresh_table
&& let Some(default) = add_column_default_token(subcommand)
&& !is_sqlite_constant_default(&default)
{
findings.push(SafetyFinding {
operation: "ADD COLUMN (non-constant default)".to_owned(),
risk: RiskLevel::Unsupported,
why: "SQLite's ADD COLUMN default must reduce to a constant: \
CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP, a call, and any other \
expression are rejected at apply time.",
next_action: "Use a literal DEFAULT, or add the column nullable and \
backfill it with an UPDATE.",
});
}
} else if subcommand.contains(" references ") {
findings.push(SafetyFinding {
operation: "ADD COLUMN REFERENCES (inline FK)".to_owned(),
risk: RiskLevel::PotentiallyBlocking,
why: "An inline REFERENCES constraint scans all existing rows to validate the \
foreign key, acquiring locks that can block writes on the referenced \
table.",
next_action: "Add the column without the constraint first, then add the FK \
using `ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID` and \
validate separately with `VALIDATE CONSTRAINT`.",
});
}
}
}
if normalized.starts_with("alter table") {
let subcommands = alter_table_subcommands(backend, normalized);
let all_known = subcommands
.iter()
.all(|s| is_known_alter_subcommand(backend, s));
if !all_known {
findings.push(SafetyFinding {
operation: "Unclassified ALTER TABLE".to_owned(),
risk: RiskLevel::ManualReview,
why: "Autumn cannot automatically assess the safety of this ALTER TABLE \
subcommand for a rolling deploy. Some operations (e.g. DROP CONSTRAINT, \
ALTER COLUMN SET NOT NULL, ADD CONSTRAINT) acquire table locks or validate \
existing rows.",
next_action: "Review the statement manually. If it is safe, you may suppress \
this finding by adding `-- autumn-safety: reviewed` above the \
statement.",
});
}
}
let is_create_index =
normalized.starts_with("create index") || normalized.starts_with("create unique index");
let is_concurrent = normalized.starts_with("create index concurrently ")
|| normalized.starts_with("create unique index concurrently ");
if is_create_index && !is_concurrent {
findings.push(SafetyFinding {
operation: if sqlite {
CREATE_INDEX_SQLITE_OP
} else {
CREATE_INDEX_PG_OP
}
.to_owned(),
risk: RiskLevel::PotentiallyBlocking,
why: if sqlite {
"Building an index takes SQLite's single write lock for the whole build. \
Writers block until it finishes; under WAL, readers do not."
} else {
"Non-concurrent index creation holds an exclusive table lock for the entire \
build, blocking all reads and writes."
},
next_action: if sqlite {
"SQLite has no online index build. Build the index during a low-traffic window, \
and keep the migration's transaction short."
} else {
"Use CREATE INDEX CONCURRENTLY instead. Note: concurrent index \
creation cannot run inside a transaction block."
},
});
}
if normalized.starts_with("update ")
|| normalized.starts_with("insert into ")
|| normalized.starts_with("delete from ")
|| normalized.starts_with("merge into ")
{
findings.push(SafetyFinding {
operation: "Bulk DML (data backfill)".to_owned(),
risk: RiskLevel::DataBackfill,
why: "Running bulk UPDATE or INSERT inside a migration locks rows for the duration \
of the transaction. On large tables this can time out or block application \
traffic for seconds to minutes.",
next_action: "Run the data backfill as a separate idempotent background job or \
one-off task (`autumn task`) after the schema migration has deployed. \
Add a NOT VALID constraint first if you need the constraint enforced \
before the backfill completes.",
});
}
if normalized.starts_with("with ")
&& (normalized.contains(") update ")
|| normalized.contains(") delete ")
|| normalized.contains(") insert into ")
|| normalized.contains("(update ")
|| normalized.contains("(delete ")
|| normalized.contains("(insert into "))
{
findings.push(SafetyFinding {
operation: "Bulk DML (data backfill via CTE)".to_owned(),
risk: RiskLevel::DataBackfill,
why: "A CTE that writes (UPDATE, DELETE, INSERT) locks rows for the duration of the \
transaction. On large tables this can time out or block application traffic.",
next_action: "Run the data backfill as a separate idempotent background job or \
one-off task (`autumn task`) after the schema migration has deployed.",
});
}
if !sqlite
&& normalized.starts_with("drop index")
&& !normalized.starts_with("drop index concurrently ")
{
findings.push(SafetyFinding {
operation: "DROP INDEX (non-concurrent)".to_owned(),
risk: RiskLevel::PotentiallyBlocking,
why: "Non-concurrent DROP INDEX acquires an AccessExclusiveLock on the table, \
blocking all reads and writes for the duration of the drop.",
next_action: "Use DROP INDEX CONCURRENTLY to avoid the exclusive table lock. \
Add `run_in_transaction = false` to the migration's `metadata.toml`.",
});
}
if !sqlite && normalized.starts_with("alter type") && normalized.contains(" rename value ") {
findings.push(SafetyFinding {
operation: "ALTER TYPE RENAME VALUE".to_owned(),
risk: RiskLevel::Irreversible,
why: "Renaming an enum label breaks old replicas that still insert, compare, or \
decode the old label. Errors will appear immediately during a rolling deploy.",
next_action: "Use expand/contract: add a new enum value, migrate all writes to use \
it, then remove the old value in a subsequent release.",
});
return findings;
}
if !sqlite && normalized.starts_with("alter type") && normalized.contains(" rename to ") {
findings.push(SafetyFinding {
operation: "ALTER TYPE RENAME".to_owned(),
risk: RiskLevel::Irreversible,
why: "Renaming a type breaks all references to its old name in old replicas, \
causing errors during a rolling deploy.",
next_action: "Coordinate a maintenance window or use expand/contract by creating \
the new type, migrating columns/code, then dropping the old one.",
});
return findings;
}
let is_known_start = normalized.starts_with("drop table")
|| normalized.starts_with("drop index")
|| normalized.starts_with("alter table") || normalized.starts_with("create table")
|| normalized.starts_with("create index")
|| normalized.starts_with("create unique index")
|| normalized.starts_with("update ")
|| normalized.starts_with("insert into ")
|| normalized.starts_with("delete from ")
|| normalized.starts_with("merge into ")
|| normalized.starts_with("truncate ")
|| normalized.starts_with("comment on")
|| normalized.starts_with("create sequence")
|| normalized.starts_with("alter sequence")
|| normalized.starts_with("drop sequence")
|| normalized.starts_with("create type")
|| normalized.starts_with("create extension")
|| normalized.starts_with("create view")
|| normalized.starts_with("drop view")
|| normalized.starts_with("select ");
let starts_with_ddl_keyword = normalized.starts_with("create ")
|| normalized.starts_with("drop ")
|| normalized.starts_with("alter ")
|| normalized.starts_with("truncate ")
|| normalized.starts_with("grant ")
|| normalized.starts_with("revoke ");
if starts_with_ddl_keyword && !is_known_start {
findings.push(SafetyFinding {
operation: "Unclassified DDL".to_owned(),
risk: RiskLevel::ManualReview,
why: "Autumn cannot automatically assess the safety of this statement for a rolling \
deploy. Operator review is required before applying this migration in \
production.",
next_action: "Review the statement manually. If it is safe, you may suppress this \
finding by adding `-- autumn-safety: reviewed` above the statement.",
});
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn risk_level_ordering() {
assert!(RiskLevel::Safe < RiskLevel::PotentiallyBlocking);
assert!(RiskLevel::PotentiallyBlocking < RiskLevel::Destructive);
assert!(RiskLevel::Destructive < RiskLevel::Irreversible);
assert!(RiskLevel::Irreversible < RiskLevel::DataBackfill);
assert!(RiskLevel::DataBackfill < RiskLevel::ManualReview);
}
#[test]
fn risk_level_display() {
assert_eq!(RiskLevel::Safe.to_string(), "safe");
assert_eq!(
RiskLevel::PotentiallyBlocking.to_string(),
"potentially-blocking"
);
assert_eq!(RiskLevel::Destructive.to_string(), "destructive");
assert_eq!(RiskLevel::Irreversible.to_string(), "irreversible");
assert_eq!(RiskLevel::DataBackfill.to_string(), "data-backfill");
assert_eq!(RiskLevel::ManualReview.to_string(), "manual-review");
}
#[test]
fn empty_sql_has_no_findings() {
assert!(classify_sql("").is_empty());
}
#[test]
fn create_table_is_safe() {
let sql = "CREATE TABLE posts (\n id BIGSERIAL PRIMARY KEY,\n title TEXT NOT NULL\n);";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"CREATE TABLE should be safe: {findings:?}"
);
}
#[test]
fn add_nullable_column_is_safe() {
let sql = "ALTER TABLE posts ADD COLUMN subtitle TEXT NULL;";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"ADD COLUMN NULL should be safe: {findings:?}"
);
}
#[test]
fn add_not_null_column_with_default_is_safe() {
let sql = "ALTER TABLE posts ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"ADD COLUMN NOT NULL DEFAULT should be safe: {findings:?}"
);
}
#[test]
fn add_not_null_column_name_containing_default_is_blocking() {
let sql = "ALTER TABLE posts ADD COLUMN defaulted_at TIMESTAMP NOT NULL;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"column name containing 'default' must not suppress finding"
);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
}
#[test]
fn create_concurrent_index_is_safe() {
let sql = "CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"CREATE INDEX CONCURRENTLY should be safe: {findings:?}"
);
}
#[test]
fn create_unique_index_concurrently_is_safe() {
let sql = "CREATE UNIQUE INDEX CONCURRENTLY idx_posts_slug ON posts (slug);";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"CREATE UNIQUE INDEX CONCURRENTLY should be safe: {findings:?}"
);
}
#[test]
fn drop_view_is_destructive() {
let findings = classify_sql("DROP VIEW active_posts;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
assert_eq!(findings[0].operation, "DROP VIEW");
}
#[test]
fn drop_view_cascade_is_destructive() {
let findings = classify_sql("DROP VIEW active_posts CASCADE;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn drop_table_is_destructive() {
let findings = classify_sql("DROP TABLE posts;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
assert_eq!(findings[0].operation, "DROP TABLE");
}
#[test]
fn drop_sequence_is_destructive() {
let findings = classify_sql("DROP SEQUENCE posts_id_seq;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
assert_eq!(findings[0].operation, "DROP SEQUENCE");
}
#[test]
fn drop_sequence_cascade_is_destructive() {
let findings = classify_sql("DROP SEQUENCE posts_id_seq CASCADE;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn drop_column_is_destructive() {
let findings = classify_sql("ALTER TABLE posts DROP COLUMN title;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
assert_eq!(findings[0].operation, "DROP COLUMN");
}
#[test]
fn drop_column_case_insensitive() {
let findings = classify_sql("alter table posts drop column title;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn alter_column_type_is_destructive() {
let findings = classify_sql("ALTER TABLE posts ALTER COLUMN score TYPE BIGINT;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
assert_eq!(findings[0].operation, "ALTER COLUMN TYPE");
}
#[test]
fn rename_column_is_irreversible() {
let findings = classify_sql("ALTER TABLE posts RENAME COLUMN body TO content;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Irreversible);
assert_eq!(findings[0].operation, "RENAME COLUMN");
}
#[test]
fn rename_table_is_irreversible() {
let findings = classify_sql("ALTER TABLE posts RENAME TO articles;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Irreversible);
assert_eq!(findings[0].operation, "RENAME TABLE");
}
#[test]
fn alter_type_rename_value_is_irreversible() {
let findings = classify_sql("ALTER TYPE status RENAME VALUE 'draft' TO 'pending';");
assert_eq!(
findings.len(),
1,
"ALTER TYPE RENAME VALUE must be flagged: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::Irreversible);
assert_eq!(findings[0].operation, "ALTER TYPE RENAME VALUE");
}
#[test]
fn alter_type_rename_to_is_irreversible() {
let findings = classify_sql("ALTER TYPE order_status RENAME TO status;");
assert_eq!(
findings.len(),
1,
"ALTER TYPE RENAME TO must be flagged: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::Irreversible);
assert_eq!(findings[0].operation, "ALTER TYPE RENAME");
}
#[test]
fn alter_type_add_value_requires_manual_review() {
let findings = classify_sql("ALTER TYPE status ADD VALUE 'archived';");
assert_eq!(
findings.len(),
1,
"unclassified ALTER TYPE must require manual review: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::ManualReview);
}
#[test]
fn rename_constraint_requires_manual_review() {
let findings = classify_sql("ALTER TABLE users RENAME CONSTRAINT old_name TO new_name;");
assert_eq!(
findings.len(),
1,
"RENAME CONSTRAINT must not silently pass: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::ManualReview);
assert_eq!(findings[0].operation, "Unclassified ALTER TABLE");
}
#[test]
fn multi_column_add_only_flags_clause_without_default() {
let sql = "ALTER TABLE posts \
ADD COLUMN score INT NOT NULL DEFAULT 0, \
ADD COLUMN slug TEXT NOT NULL;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"only the column without a DEFAULT should be flagged"
);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
}
#[test]
fn mixed_known_and_unknown_alter_table_subcommands_flagged() {
let sql = "ALTER TABLE posts ADD COLUMN subtitle TEXT, DROP CONSTRAINT posts_title_key;";
let findings = classify_sql(sql);
assert!(
findings.iter().any(|f| f.risk == RiskLevel::ManualReview),
"unknown subcommand must produce ManualReview: {findings:?}"
);
}
#[test]
fn add_not_null_column_without_default_is_blocking() {
let findings = classify_sql("ALTER TABLE posts ADD COLUMN score INTEGER NOT NULL;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
assert_eq!(findings[0].operation, "ADD COLUMN NOT NULL (no default)");
}
#[test]
fn add_not_null_column_with_volatile_default_is_blocking() {
let findings = classify_sql(
"ALTER TABLE posts ADD COLUMN token UUID NOT NULL DEFAULT gen_random_uuid();",
);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
assert_eq!(
findings[0].operation,
"ADD COLUMN NOT NULL (volatile default)"
);
}
#[test]
fn add_not_null_column_with_now_default_is_safe() {
let findings = classify_sql(
"ALTER TABLE posts ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now();",
);
assert!(
findings.is_empty(),
"DEFAULT now() is stable and must not be flagged as volatile: {findings:?}"
);
}
#[test]
fn add_not_null_column_with_parenthesized_constant_default_is_safe() {
let findings_int =
classify_sql("ALTER TABLE posts ADD COLUMN score INT NOT NULL DEFAULT (0);");
assert!(
findings_int.is_empty(),
"DEFAULT (0) must be safe: {findings_int:?}"
);
let findings_str =
classify_sql("ALTER TABLE posts ADD COLUMN status TEXT NOT NULL DEFAULT ('draft');");
assert!(
findings_str.is_empty(),
"DEFAULT ('draft') must be safe: {findings_str:?}"
);
}
#[test]
fn add_not_null_column_with_constant_default_is_safe() {
let findings =
classify_sql("ALTER TABLE posts ADD COLUMN active BOOLEAN NOT NULL DEFAULT false;");
assert!(
findings.is_empty(),
"constant DEFAULT false must be safe: {findings:?}"
);
}
#[test]
fn add_column_with_inline_unique_is_potentially_blocking() {
let findings = classify_sql("ALTER TABLE posts ADD COLUMN slug TEXT UNIQUE;");
assert_eq!(
findings.len(),
1,
"inline UNIQUE must be flagged: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
assert_eq!(
findings[0].operation,
"ADD COLUMN UNIQUE (inline constraint)"
);
}
#[test]
fn add_column_with_inline_references_is_potentially_blocking() {
let findings =
classify_sql("ALTER TABLE posts ADD COLUMN user_id INT REFERENCES users(id);");
assert_eq!(
findings.len(),
1,
"inline REFERENCES must be flagged: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
assert_eq!(findings[0].operation, "ADD COLUMN REFERENCES (inline FK)");
}
#[test]
fn add_column_with_primary_key_requires_manual_review() {
let findings = classify_sql("ALTER TABLE posts ADD COLUMN id BIGSERIAL PRIMARY KEY;");
assert!(
findings.iter().any(|f| f.risk == RiskLevel::ManualReview),
"inline PRIMARY KEY must require manual review: {findings:?}"
);
}
#[test]
fn add_column_without_constraints_does_not_trigger_manual_review() {
let findings = classify_sql("ALTER TABLE posts ADD COLUMN subtitle TEXT;");
assert!(
findings.iter().all(|f| f.risk != RiskLevel::ManualReview),
"simple ADD COLUMN must not trigger ManualReview: {findings:?}"
);
}
#[test]
fn create_non_concurrent_index_is_blocking() {
let findings = classify_sql("CREATE INDEX idx_posts_title ON posts (title);");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
assert_eq!(findings[0].operation, "CREATE INDEX (non-concurrent)");
}
#[test]
fn create_unique_index_without_concurrently_is_blocking() {
let findings = classify_sql("CREATE UNIQUE INDEX idx_posts_slug ON posts (slug);");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
assert_eq!(findings[0].operation, "CREATE INDEX (non-concurrent)");
}
#[test]
fn non_concurrent_index_on_newly_created_table_is_safe() {
let sql = "CREATE TABLE posts (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL);\n\
CREATE INDEX idx_posts_title ON posts (title);";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"non-concurrent index on a table created in the same migration must be safe: \
{findings:?}"
);
}
#[test]
fn unique_non_concurrent_index_on_newly_created_table_is_safe() {
let sql = "CREATE TABLE posts (id BIGSERIAL PRIMARY KEY, slug TEXT NOT NULL);\n\
CREATE UNIQUE INDEX idx_posts_slug ON posts (slug);";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"non-concurrent unique index on a newly created table must be safe: {findings:?}"
);
}
#[test]
fn non_concurrent_index_on_different_table_is_still_blocking() {
let sql = "CREATE TABLE posts (id BIGSERIAL PRIMARY KEY);\n\
CREATE INDEX idx_comments_post_id ON comments (post_id);";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"non-concurrent index on a pre-existing table must still be flagged: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
}
#[test]
fn if_not_exists_table_not_treated_as_newly_created() {
let sql = "CREATE TABLE IF NOT EXISTS posts (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL);\n\
CREATE INDEX idx_posts_title ON posts (title);";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"non-concurrent index on IF NOT EXISTS table must still be flagged"
);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
}
#[test]
fn truncate_table_is_destructive() {
let findings = classify_sql("TRUNCATE TABLE posts;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
assert_eq!(findings[0].operation, "TRUNCATE");
}
#[test]
fn multiple_safe_statements_produce_no_findings() {
let sql = "\
ALTER TABLE posts ADD COLUMN subtitle TEXT NULL;\n\
CREATE INDEX CONCURRENTLY idx_posts_subtitle ON posts (subtitle);";
let findings = classify_sql(sql);
assert!(findings.is_empty());
}
#[test]
fn mixed_safe_and_unsafe_statements_produces_findings_for_unsafe() {
let sql = "\
ALTER TABLE posts ADD COLUMN subtitle TEXT NULL;\n\
ALTER TABLE posts DROP COLUMN body;";
let findings = classify_sql(sql);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn multiple_unsafe_statements_produce_multiple_findings() {
let sql = "\
ALTER TABLE posts DROP COLUMN body;\n\
CREATE INDEX idx_posts_title ON posts (title);";
let findings = classify_sql(sql);
assert_eq!(findings.len(), 2);
assert!(findings.iter().any(|f| f.risk == RiskLevel::Destructive));
assert!(
findings
.iter()
.any(|f| f.risk == RiskLevel::PotentiallyBlocking)
);
}
#[test]
fn sql_with_line_comments_is_classified_correctly() {
let sql = "-- Removing old column\nALTER TABLE posts DROP COLUMN body;";
let findings = classify_sql(sql);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn line_comment_with_semicolon_does_not_hide_following_statement() {
let sql = "-- rollout complete; safe to proceed\nDROP TABLE posts;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"DROP TABLE must be found after a line comment containing ';': {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn autumn_safety_comment_does_not_double_classify() {
let sql = "-- autumn-safety: destructive\nALTER TABLE posts DROP COLUMN body;";
let findings = classify_sql(sql);
assert_eq!(findings.len(), 1);
}
#[test]
fn autumn_safety_reviewed_suppresses_manual_review_finding() {
let sql = "-- autumn-safety: reviewed\nCREATE FUNCTION noop() RETURNS void LANGUAGE sql AS $$SELECT 1$$;";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"reviewed marker must suppress ManualReview finding: {findings:?}"
);
}
#[test]
fn autumn_safety_reviewed_suppresses_unclassified_alter_table() {
let sql = "-- autumn-safety: reviewed\nALTER TABLE users DROP CONSTRAINT users_email_key;";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"reviewed marker must suppress finding: {findings:?}"
);
}
#[test]
fn autumn_safety_destructive_does_not_suppress() {
let sql = "-- autumn-safety: destructive\nALTER TABLE posts DROP COLUMN body;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"non-reviewed marker must not suppress findings"
);
}
#[test]
fn is_safe_returns_true_for_empty() {
assert!(is_safe(&[]));
}
#[test]
fn is_safe_returns_false_for_unsafe_findings() {
let f = SafetyFinding {
operation: "DROP COLUMN".to_owned(),
risk: RiskLevel::Destructive,
why: "",
next_action: "",
};
assert!(!is_safe(&[f]));
}
#[test]
fn has_unsafe_findings_returns_false_for_empty() {
assert!(!has_unsafe_findings(&[]));
}
#[test]
fn has_unsafe_findings_returns_true_for_blocking() {
let f = SafetyFinding {
operation: "CREATE INDEX (non-concurrent)".to_owned(),
risk: RiskLevel::PotentiallyBlocking,
why: "",
next_action: "",
};
assert!(has_unsafe_findings(&[f]));
}
#[test]
fn drop_column_finding_names_the_risk_and_next_action() {
let findings = classify_sql("ALTER TABLE posts DROP COLUMN body;");
let f = &findings[0];
assert!(
!f.why.is_empty(),
"why must explain the rolling-deploy risk"
);
assert!(
!f.next_action.is_empty(),
"next_action must tell the operator what to do"
);
}
#[test]
fn non_concurrent_index_finding_mentions_concurrently() {
let findings = classify_sql("CREATE INDEX idx ON posts (title);");
let f = &findings[0];
assert!(
f.next_action.to_lowercase().contains("concurrently"),
"next_action should recommend CONCURRENTLY: {}",
f.next_action
);
}
#[test]
fn merge_into_is_data_backfill() {
let sql = "MERGE INTO posts AS target \
USING staging AS src ON target.id = src.id \
WHEN MATCHED THEN UPDATE SET title = src.title \
WHEN NOT MATCHED THEN INSERT (id, title) VALUES (src.id, src.title);";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"MERGE INTO must be classified as a data backfill: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::DataBackfill);
assert_eq!(findings[0].operation, "Bulk DML (data backfill)");
}
#[test]
fn bulk_update_is_data_backfill() {
let findings = classify_sql("UPDATE posts SET status = 'draft' WHERE status IS NULL;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::DataBackfill);
assert_eq!(findings[0].operation, "Bulk DML (data backfill)");
}
#[test]
fn insert_select_is_data_backfill() {
let findings =
classify_sql("INSERT INTO post_tags (post_id, tag) SELECT id, 'untagged' FROM posts;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::DataBackfill);
}
#[test]
fn bulk_delete_is_data_backfill() {
let findings = classify_sql("DELETE FROM posts WHERE created_at < '2020-01-01';");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::DataBackfill);
assert_eq!(findings[0].operation, "Bulk DML (data backfill)");
}
#[test]
fn data_backfill_finding_recommends_separate_job() {
let findings = classify_sql("UPDATE posts SET slug = LOWER(title);");
let f = &findings[0];
assert!(!f.why.is_empty());
assert!(
f.next_action.to_lowercase().contains("background job")
|| f.next_action.to_lowercase().contains("task"),
"next_action should recommend a separate job or task: {}",
f.next_action
);
}
#[test]
fn cte_update_is_data_backfill() {
let sql = "WITH batch AS (SELECT id FROM posts WHERE status IS NULL LIMIT 1000) \
UPDATE posts SET status = 'draft' FROM batch WHERE posts.id = batch.id;";
let findings = classify_sql(sql);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::DataBackfill);
assert_eq!(findings[0].operation, "Bulk DML (data backfill via CTE)");
}
#[test]
fn cte_delete_is_data_backfill() {
let sql = "WITH doomed AS (SELECT id FROM posts WHERE archived = true) DELETE FROM posts USING doomed WHERE posts.id = doomed.id;";
let findings = classify_sql(sql);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::DataBackfill);
}
#[test]
fn cte_select_only_is_safe() {
let sql = "WITH recent AS (SELECT id FROM posts ORDER BY created_at DESC LIMIT 10) \
SELECT * FROM recent;";
let findings = classify_sql(sql);
assert!(
findings.iter().all(|f| f.risk != RiskLevel::DataBackfill),
"read-only CTE must not produce DataBackfill finding: {findings:?}"
);
}
#[test]
fn cte_body_write_with_outer_select_is_data_backfill() {
let sql = "WITH touched AS \
(UPDATE posts SET migrated = true WHERE migrated IS NULL RETURNING id) \
SELECT count(*) FROM touched;";
let findings = classify_sql(sql);
assert!(
findings.iter().any(|f| f.risk == RiskLevel::DataBackfill),
"data-modifying CTE with outer SELECT must still be flagged: {findings:?}"
);
}
#[test]
fn create_function_requires_manual_review() {
let sql = "CREATE FUNCTION update_modified() RETURNS trigger AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql;";
let findings = classify_sql(sql);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::ManualReview);
assert_eq!(findings[0].operation, "Unclassified DDL");
}
#[test]
fn truncate_is_destructive() {
let findings = classify_sql("TRUNCATE TABLE staging_data;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn drop_constraint_requires_manual_review() {
let findings = classify_sql("ALTER TABLE users DROP CONSTRAINT users_email_key;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::ManualReview);
assert_eq!(findings[0].operation, "Unclassified ALTER TABLE");
}
#[test]
fn alter_column_set_not_null_requires_manual_review() {
let findings = classify_sql("ALTER TABLE users ALTER COLUMN email SET NOT NULL;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::ManualReview);
assert_eq!(findings[0].operation, "Unclassified ALTER TABLE");
}
#[test]
fn known_ddl_does_not_trigger_manual_review() {
let findings = classify_sql("CREATE TABLE comments (id BIGSERIAL PRIMARY KEY);");
assert!(
findings.iter().all(|f| f.risk != RiskLevel::ManualReview),
"known DDL should not also produce ManualReview: {findings:?}"
);
}
#[test]
fn add_column_does_not_trigger_unclassified_alter_table() {
let findings = classify_sql("ALTER TABLE posts ADD COLUMN subtitle TEXT NULL;");
assert!(
findings.iter().all(|f| f.risk != RiskLevel::ManualReview),
"safe ADD COLUMN should not produce ManualReview: {findings:?}"
);
}
#[test]
fn contains_concurrent_index_true_for_executable_statement() {
assert!(contains_concurrent_index(
"CREATE INDEX CONCURRENTLY idx ON posts (title);"
));
assert!(contains_concurrent_index(
"CREATE UNIQUE INDEX CONCURRENTLY idx ON posts (slug);"
));
}
#[test]
fn contains_concurrent_index_false_for_non_concurrent() {
assert!(!contains_concurrent_index(
"CREATE INDEX idx ON posts (title);"
));
}
#[test]
fn contains_concurrent_index_false_for_comment_only_mention() {
let sql = "-- TODO: use CREATE INDEX CONCURRENTLY later\n\
CREATE INDEX idx ON posts (title);";
assert!(
!contains_concurrent_index(sql),
"a CONCURRENTLY reference only in a comment must return false"
);
}
#[test]
fn contains_concurrent_index_true_for_multiline_statement() {
let sql = "CREATE INDEX\n CONCURRENTLY idx_posts_title ON posts (title);";
assert!(
contains_concurrent_index(sql),
"multi-line CONCURRENTLY statement must be detected"
);
}
#[test]
fn contains_concurrent_index_true_for_drop_index_concurrently() {
assert!(
contains_concurrent_index("DROP INDEX CONCURRENTLY idx_posts_title;"),
"DROP INDEX CONCURRENTLY must be detected"
);
}
#[test]
fn block_comment_before_drop_table_is_still_classified() {
let sql = "/* cleanup old table */ DROP TABLE posts;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"DROP TABLE must be found after block comment"
);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn block_comment_before_create_index_is_still_classified() {
let sql = "/* needs index */ CREATE INDEX idx ON posts (title);";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"CREATE INDEX must be found after block comment"
);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
}
#[test]
fn multiline_block_comment_is_stripped() {
let sql = "/*\n * Remove legacy column\n */\nALTER TABLE posts DROP COLUMN body;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"DROP COLUMN must be found after multi-line block comment"
);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn block_comment_only_mention_of_keyword_is_not_classified() {
let sql = "/* was: DROP TABLE posts; */ ALTER TABLE posts ADD COLUMN active BOOL;";
let findings = classify_sql(sql);
assert!(
findings.iter().all(|f| f.risk != RiskLevel::Destructive),
"Destructive keyword inside block comment must not produce a Destructive finding"
);
}
#[test]
fn block_comment_with_semicolon_does_not_hide_following_statement() {
let sql = "/* cleanup; safe to drop */ DROP TABLE posts;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"DROP TABLE after a block comment containing ';' must still be classified"
);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn block_open_inside_line_comment_does_not_hide_statements() {
for backend in [DatabaseBackend::Postgres, DatabaseBackend::Sqlite] {
for sql in [
"--/*\nDROP TABLE t;",
"-- load /var/data/*.csv later\nDROP TABLE t;",
] {
let findings = classify_sql_for(backend, sql);
assert_eq!(findings.len(), 1, "{backend:?} {sql:?}");
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
}
}
#[test]
fn block_open_inside_literal_does_not_hide_statements() {
let sql = "INSERT INTO t VALUES ('/*');\nDROP TABLE t;";
let findings = classify_sql(sql);
assert!(findings.iter().any(|f| f.risk == RiskLevel::Destructive));
}
#[test]
fn line_comment_marker_inside_literal_does_not_hide_clause() {
let sql = "ALTER TABLE t ADD COLUMN a text DEFAULT '--', DROP COLUMN b;";
let findings = classify_sql(sql);
assert!(
findings.iter().any(|f| f.operation == "DROP COLUMN"),
"{findings:?}"
);
}
#[test]
fn review_marker_survives_block_stripping() {
let sql = "-- autumn-safety: reviewed /* ok\nDROP TABLE t;";
assert!(classify_sql(sql).is_empty());
}
#[test]
fn block_comment_after_line_comment_is_still_stripped() {
let sql = "-- note\n/* DROP TABLE a; */ SELECT 1;";
assert!(classify_sql(sql).is_empty());
}
#[test]
fn has_executable_sql_sees_statement_after_line_comment_with_block_open() {
assert!(has_executable_sql(
DatabaseBackend::Postgres,
"-- x /* y\nDROP TABLE t;"
));
assert!(!has_executable_sql(
DatabaseBackend::Postgres,
"-- x /* y\n-- z"
));
}
#[test]
fn concurrent_index_scan_strips_block_comments_first() {
assert!(contains_concurrent_index(
"CREATE INDEX /* a; b */ CONCURRENTLY idx ON t (c);"
));
assert!(!contains_concurrent_index(
"/* x; CREATE INDEX CONCURRENTLY i ON t (c) */"
));
}
#[test]
fn apostrophe_in_dollar_quote_does_not_keep_later_comment() {
let sql = "ALTER TABLE t ADD COLUMN a text DEFAULT $$it's$$ -- drop column b\n;";
assert!(
classify_sql(sql)
.iter()
.all(|f| f.operation != "DROP COLUMN")
);
}
#[test]
fn unclosed_spans_run_to_end_of_input() {
let sql = "DROP TABLE a; SELECT 'oops --/* DROP TABLE b;";
assert_eq!(classify_sql(sql).len(), 1);
assert_eq!(
strip_line_comments(DatabaseBackend::Postgres, "SELECT 'a -- b"),
"SELECT 'a -- b"
);
assert_eq!(
strip_block_comments(
DatabaseBackend::Postgres,
"DROP TABLE a; /* open\nDROP TABLE b;"
),
"DROP TABLE a; "
);
assert_eq!(strip_block_comments(DatabaseBackend::Postgres, "/*/"), " ");
let dollar = "SELECT $$ /* x\nDROP TABLE t;";
assert_eq!(
strip_block_comments(DatabaseBackend::Postgres, dollar),
dollar
);
}
#[test]
fn block_comments_nest_on_postgres_only() {
let sql = "/* a /* b */ DROP TABLE t; */";
assert!(classify_sql(sql).is_empty());
assert_eq!(ops(DatabaseBackend::Sqlite, sql), ["DROP TABLE"]);
}
#[test]
fn multibyte_text_does_not_break_scanning() {
let sql = "-- é/*\nDROP TABLE tést; /* ü */ SELECT '日本--', $é$ ; $é$;";
let findings = classify_sql(sql);
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn lexer_helpers_measure_spans() {
assert_eq!(doubled_quote_len("'a''b'x", '\''), 6);
assert_eq!(doubled_quote_len("`a", '`'), 2);
assert_eq!(escape_string_len(r"'a\'b'x"), 6);
assert_eq!(escape_string_len(r"'a''b'x"), 6);
assert_eq!(escape_string_len(r"'a\"), 3);
let lite = DatabaseBackend::Sqlite;
assert_eq!(quoted_len(lite, "[a b", None), Some(4));
assert_eq!(quoted_len(lite, "$$x$$", None), None);
let pg = DatabaseBackend::Postgres;
assert_eq!(quoted_len(pg, "`a`", None), None);
assert_eq!(quoted_len(pg, "[a]", None), None);
assert_eq!(quoted_len(pg, "E'a'", Some('x')), None);
assert_eq!(quoted_len(pg, "$b$x$b$", Some('a')), None);
assert_eq!(blank_quoted_spans(pg, "a 'bc d"), "a ' d");
assert_eq!(dollar_quote_len("$1 AND"), None);
assert_eq!(dollar_quote_len("$a b$ x"), None);
assert_eq!(dollar_quote_len("$$x$$y"), Some(5));
assert_eq!(
line_comment_len(DatabaseBackend::Postgres, "--a\nb"),
Some(3)
);
assert_eq!(line_comment_len(DatabaseBackend::Postgres, "a--"), None);
}
#[test]
fn quote_in_line_comment_opens_no_literal() {
let findings = classify_sql("-- don't\nDROP TABLE t;");
assert_eq!(findings.len(), 1);
assert_eq!(
strip_line_comments(DatabaseBackend::Postgres, "-- don't\nx"),
"\nx"
);
}
#[test]
fn dollar_quote_hides_line_comment_marker() {
let sql = "DO $$ BEGIN -- x\n END $$";
assert_eq!(
normalize_statement(DatabaseBackend::Postgres, sql),
"do $$ BEGIN -- x\n END $$"
);
}
#[test]
fn has_executable_sql_follows_the_dialect() {
let sql = "/* a /* */ DROP TABLE t;";
assert!(has_executable_sql(DatabaseBackend::Sqlite, sql));
assert!(!has_executable_sql(DatabaseBackend::Postgres, sql));
}
#[test]
fn quoted_table_names_follow_the_dialect_case_rule() {
let lite = DatabaseBackend::Sqlite;
let sql =
r#"CREATE TABLE "Users" (id int); ALTER TABLE "users" ADD COLUMN z int NOT NULL;"#;
assert!(is_safe(&classify_sql_for(lite, sql)), "{sql}");
let sql = r#"CREATE TABLE "Users" (id int); CREATE INDEX i ON "users" (id);"#;
assert_eq!(ops(lite, sql), Vec::<String>::new());
assert_eq!(
ops(DatabaseBackend::Postgres, sql),
["CREATE INDEX (non-concurrent)"]
);
}
#[test]
fn has_executable_sql_edge_inputs() {
assert!(has_executable_sql(
DatabaseBackend::Postgres,
"SELECT '--';"
));
assert!(!has_executable_sql(DatabaseBackend::Postgres, ""));
assert!(!has_executable_sql(DatabaseBackend::Postgres, "/* only */"));
}
#[test]
fn identifier_quoting_forms_do_not_hide_later_statements() {
for (backend, sql) in [
(
DatabaseBackend::Postgres,
"SELECT ARRAY[$$]$$]; DROP TABLE t;",
),
(
DatabaseBackend::Postgres,
"SELECT ARRAY['a]']; DROP TABLE t;",
),
(
DatabaseBackend::Sqlite,
"CREATE TABLE `a\"b` (id int); DROP TABLE t;",
),
(
DatabaseBackend::Sqlite,
"CREATE TABLE [a\"b] (id int); DROP TABLE t;",
),
] {
let f = classify_sql_for(backend, sql);
assert!(
f.iter().any(|f| f.risk == RiskLevel::Destructive),
"{sql}: {f:?}"
);
}
}
fn ops(backend: DatabaseBackend, sql: &str) -> Vec<String> {
classify_sql_for(backend, sql)
.into_iter()
.map(|f| f.operation)
.collect()
}
#[test]
fn markers_in_quoted_identifiers_hide_no_statement() {
let pg = DatabaseBackend::Postgres;
let lite = DatabaseBackend::Sqlite;
let mut cases = Vec::new();
for backend in [pg, lite] {
for ident in [
r#""a/*b""#,
r#""a--b""#,
r#""a'b""#,
r#""a;drop table y""#,
r#""a""/*b""#,
] {
cases.push((backend, ident));
}
}
for ident in [
"`a/*b`",
"`a--b`",
"`a'b`",
"`a;drop table y`",
"`a``/*b`",
"[a/*b]",
"[a--b]",
"[a'b]",
"[a;drop table y]",
] {
cases.push((lite, ident));
}
for (backend, ident) in cases {
let sql = format!("CREATE TABLE {ident} (id int); DROP TABLE x;");
assert_eq!(ops(backend, &sql), ["DROP TABLE"], "{backend:?} {sql}");
let sql = format!("CREATE TABLE {ident} (id int);\nDROP TABLE x;");
assert_eq!(ops(backend, &sql), ["DROP TABLE"], "{backend:?} {sql}");
}
}
#[test]
fn escape_string_hides_no_statement() {
for lit in [
r"E'\''",
r"e'\''",
r"E'a\'b'",
r"E'\\'",
r"E'\'--/*;'",
r"E'it''s; x'",
r"E'\é'",
r#""x"E'\''"#,
r"U&'\0041'",
r"'C:\'",
r"xe'\'",
] {
let sql = format!("SELECT {lit}; DROP TABLE x;");
assert_eq!(
ops(DatabaseBackend::Postgres, &sql),
["DROP TABLE"],
"{sql}"
);
}
}
#[test]
fn dollar_quote_rules_follow_the_dialect() {
let sql = "CREATE TABLE t (a$b$ int); DROP TABLE x;";
for backend in [DatabaseBackend::Postgres, DatabaseBackend::Sqlite] {
assert_eq!(ops(backend, sql), ["DROP TABLE"], "{backend:?}");
}
let sql = "SELECT $y$z; DROP TABLE x;";
assert_eq!(ops(DatabaseBackend::Sqlite, sql), ["DROP TABLE"]);
let sql = "CREATE FUNCTION f() RETURNS int AS $b$ SELECT 1; $b$ LANGUAGE sql;";
assert_eq!(ops(DatabaseBackend::Postgres, sql), ["Unclassified DDL"]);
let sql = r"SELECT E'\'; DROP TABLE x;";
assert_eq!(ops(DatabaseBackend::Sqlite, sql), ["DROP TABLE"]);
}
#[test]
fn quote_in_identifier_hides_no_clause() {
let pg = DatabaseBackend::Postgres;
let lite = DatabaseBackend::Sqlite;
let sql = r#"ALTER TABLE t ADD COLUMN "it's" text UNIQUE;"#;
assert_eq!(ops(pg, sql), ["ADD COLUMN UNIQUE (inline constraint)"]);
let sql = "ALTER TABLE t ADD COLUMN [it's] text UNIQUE;";
assert_eq!(
ops(lite, sql),
["ADD COLUMN UNIQUE (unsupported on SQLite)"]
);
let sql = r#"CREATE TABLE "it's" (id int) PARTITION BY RANGE (id);"#;
assert_eq!(
ops(lite, sql),
["CREATE TABLE with a Postgres-only clause (unsupported on SQLite)"]
);
for sql in [
r"ALTER TABLE t ADD COLUMN a text DEFAULT E'\' unique ';",
"ALTER TABLE t ADD COLUMN a text DEFAULT $$ not unique $$;",
] {
assert_eq!(ops(pg, sql), Vec::<String>::new(), "{sql}");
}
}
#[test]
fn comma_in_quoted_span_splits_no_alter_subcommand() {
for (backend, sql) in [
(
DatabaseBackend::Postgres,
r#"ALTER TABLE t ADD COLUMN "a,b" text DEFAULT 'x, y', DROP COLUMN c;"#,
),
(
DatabaseBackend::Postgres,
r"ALTER TABLE t ADD COLUMN a text DEFAULT E'\', y', DROP COLUMN c;",
),
(
DatabaseBackend::Postgres,
"ALTER TABLE t ADD COLUMN a text DEFAULT $$x, y$$, DROP COLUMN c;",
),
(DatabaseBackend::Sqlite, "ALTER TABLE t DROP COLUMN [a,b];"),
] {
assert_eq!(ops(backend, sql), ["DROP COLUMN"], "{backend:?} {sql}");
}
}
#[test]
fn quoted_table_name_merges_no_alter_subcommand() {
let pg = DatabaseBackend::Postgres;
for table in [r#""my table""#, r#"s."my table""#] {
for head in ["", "IF EXISTS ", "ONLY ", "IF EXISTS ONLY "] {
let sql = format!(
"ALTER TABLE {head}{table} ADD COLUMN a int, ADD COLUMN b int NOT NULL;"
);
assert_eq!(ops(pg, &sql), ["ADD COLUMN NOT NULL (no default)"], "{sql}");
}
}
let sql = r#"ALTER TABLE "my table" ADD COLUMN b int UNIQUE;"#;
assert_eq!(ops(pg, sql), ["ADD COLUMN UNIQUE (inline constraint)"]);
let sql = "ALTER TABLE [my table] ADD COLUMN b int UNIQUE;";
assert_eq!(
ops(DatabaseBackend::Sqlite, sql),
["ADD COLUMN UNIQUE (unsupported on SQLite)"]
);
}
#[test]
fn paren_in_quoted_span_merges_no_alter_subcommand() {
let sql = "ALTER TABLE t ADD COLUMN a text DEFAULT '(', ALTER COLUMN b SET NOT NULL;";
assert_eq!(
ops(DatabaseBackend::Postgres, sql),
["Unclassified ALTER TABLE"]
);
}
#[test]
fn postgres_lexer_rules_hide_no_statement() {
for sql in [
"ALTER TABLE t ADD COLUMN c text DEFAULT $A$ $a$ ' $A$, ADD COLUMN z int NOT NULL;",
"/*\nCREATE TABLE old (id int); /* legacy */\n*/\nALTER TABLE t ADD COLUMN z int NOT NULL;",
"/* /* */ ' */ ALTER TABLE t ADD COLUMN z int NOT NULL; -- '",
"SELECT $€$/*$€$; ALTER TABLE t ADD COLUMN z int NOT NULL;",
"-- note\rALTER TABLE t ADD COLUMN z int NOT NULL;",
] {
assert_eq!(
ops(DatabaseBackend::Postgres, sql),
["ADD COLUMN NOT NULL (no default)"],
"{sql:?}"
);
}
assert_eq!(dollar_quote_len("$1$x$1$"), None);
assert_eq!(dollar_quote_len("$t1$x$t1$"), Some(9));
}
#[test]
fn sqlite_lexer_rules_hide_no_statement() {
for sql in [
"SELECT $a([); DROP TABLE x; -- ]",
"SELECT :a(\"); DROP TABLE x; -- \"",
"SELECT @a(`); DROP TABLE x; -- `",
"SELECT $a('); DROP TABLE x; SELECT '';",
] {
assert_eq!(ops(DatabaseBackend::Sqlite, sql), ["DROP TABLE"], "{sql}");
}
let sql = "/* a /* b */ DROP TABLE x;";
assert_eq!(ops(DatabaseBackend::Sqlite, sql), ["DROP TABLE"]);
let sql = "-- a\rDROP TABLE x;\nSELECT 1;";
assert_eq!(ops(DatabaseBackend::Sqlite, sql), Vec::<String>::new());
assert_eq!(sqlite_variable_len("$a(x y"), Some(4));
assert_eq!(sqlite_variable_len(":a(x)y"), Some(5));
assert_eq!(sqlite_variable_len("$("), None);
let texts: Vec<_> = tokens(DatabaseBackend::Sqlite, "a$b(")
.map(|(_, text)| text)
.collect();
assert_eq!(texts, ["a", "$", "b", "("]);
}
mod quoted_identifier_proptest {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn quoted_identifier_hides_no_statement(
body in "[a-z;'\"`\\[\\]$