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 without_block_comments = strip_block_comments(sql);
let stmts = split_statements(&without_block_comments);
let newly_created: Vec<String> = stmts
.iter()
.filter(|s| !has_review_suppression(s))
.filter_map(|s| extract_created_table_name(&normalize_statement(s)))
.collect();
stmts
.iter()
.filter(|stmt| !has_review_suppression(stmt))
.flat_map(|stmt| {
let normalized = normalize_statement(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| 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(sql: &str) -> bool {
let without_block_comments = strip_block_comments(sql);
split_statements(&without_block_comments)
.iter()
.any(|stmt| {
stmt.lines()
.any(|line| !line.trim().is_empty() && !line.trim().starts_with("--"))
})
}
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) }
}
pub fn split_statements(sql: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current = String::new();
let mut i = 0;
while i < sql.len() {
let rest = &sql[i..];
if let Some(after_dollar) = rest.strip_prefix('$')
&& let Some(close_in_rest1) = after_dollar.find('$')
{
let tag_body = &after_dollar[..close_in_rest1];
if tag_body.chars().all(|c| c.is_alphanumeric() || c == '_') {
let tag_len = 1 + close_in_rest1 + 1; let tag = &rest[..tag_len];
if let Some(close_pos) = rest[tag_len..].find(tag) {
current.push_str(&rest[..tag_len + close_pos + tag_len]);
i += tag_len + close_pos + tag_len;
} else {
current.push_str(rest);
i = sql.len();
}
continue;
}
}
if rest.starts_with('\'') {
let mut j = 1;
loop {
if let Some(rel) = rest[j..].find('\'') {
let abs = j + rel;
if rest[abs + 1..].starts_with('\'') {
j = abs + 2; } else {
j = abs + 1; break;
}
} else {
j = rest.len(); break;
}
}
current.push_str(&rest[..j]);
i += j;
continue;
}
if rest.starts_with("--") {
let end = rest.find('\n').unwrap_or(rest.len());
current.push_str(&rest[..end]);
i += end;
} else if rest.starts_with(';') {
let trimmed = current.trim().to_owned();
if !trimmed.is_empty() {
statements.push(trimmed);
}
current.clear();
i += 1;
} else {
let c = rest.chars().next().unwrap();
current.push(c);
i += c.len_utf8();
}
}
let trimmed = current.trim().to_owned();
if !trimmed.is_empty() {
statements.push(trimmed);
}
statements
}
fn alter_table_subcommands(normalized: &str, quote_aware: bool) -> Vec<&str> {
let after_prefix = normalized.strip_prefix("alter table ").unwrap_or("");
let subcommands_start = after_prefix.find(' ').map_or(after_prefix.len(), |i| i + 1);
let subcommands = &after_prefix[subcommands_start..];
let mut result = Vec::new();
let mut depth: i32 = 0;
let mut quote: Option<char> = None;
let mut start = 0;
for (i, c) in subcommands.char_indices() {
if let Some(q) = quote {
if c == q {
quote = None;
}
continue;
}
match c {
'\'' | '"' | '`' if quote_aware => quote = Some(c),
'[' if quote_aware => quote = Some(']'),
'(' => depth += 1,
')' => depth -= 1,
',' if depth == 0 => {
result.push(subcommands[start..i].trim());
start = i + 1;
}
_ => {}
}
}
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(normalized, true);
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 without_string_literals(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 = without_string_literals(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 = without_string_literals(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 = without_string_literals(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 = without_string_literals(normalized).replace("( ", "(");
["(update ", "(delete ", "(insert into "]
.iter()
.any(|p| sql.contains(p))
}
fn without_string_literals(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let mut in_literal = false;
for c in sql.chars() {
match c {
'\'' => {
in_literal = !in_literal;
out.push(c);
}
_ if in_literal => out.push(' '),
_ => out.push(c),
}
}
out
}
fn has_inline_unique_constraint(subcommand: &str) -> bool {
subcommand.contains(" unique ") || subcommand.ends_with(" unique")
}
pub fn strip_block_comments(sql: &str) -> String {
let mut result = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();
while let Some(c) = chars.next() {
if c == '/' && chars.peek() == Some(&'*') {
chars.next(); loop {
match chars.next() {
Some('*') if chars.peek() == Some(&'/') => {
chars.next(); result.push(' '); break;
}
None => break, _ => {}
}
}
} else {
result.push(c);
}
}
result
}
pub fn normalize_statement(stmt: &str) -> String {
let without_block_comments = strip_block_comments(stmt);
without_block_comments
.lines()
.map(|line| line.find("--").map_or(line, |i| &line[..i]))
.flat_map(str::split_whitespace)
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
pub fn contains_concurrent_index(sql: &str) -> bool {
split_statements(sql).iter().any(|stmt| {
let normalized = normalize_statement(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| *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(normalized, sqlite);
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(normalized, sqlite) {
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(normalized, sqlite) {
if !subcommand.starts_with("add column ") {
continue;
}
if has_inline_unique_constraint(&without_string_literals(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(normalized, sqlite);
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_comment_between_keywords_preserves_token_boundary() {
let findings = classify_sql("DROP/* note */TABLE posts;");
assert_eq!(
findings.len(),
1,
"block comment between keywords must not merge them: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::Destructive);
}
#[test]
fn dollar_quoted_function_with_semicolons_is_one_statement() {
let sql = "CREATE FUNCTION bump() RETURNS void AS $$ BEGIN \
UPDATE posts SET hits = hits + 1; RETURN; END; $$ LANGUAGE plpgsql;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"dollar-quoted body with semicolons must produce exactly one finding: {findings:?}"
);
assert_eq!(findings[0].risk, RiskLevel::ManualReview);
}
#[test]
fn autumn_safety_reviewed_suppresses_function_with_dml_in_body() {
let sql = "-- autumn-safety: reviewed\n\
CREATE FUNCTION migrate_posts() RETURNS void AS $$\n\
BEGIN\n UPDATE posts SET migrated = true;\n RETURN;\nEND;\n\
$$ LANGUAGE plpgsql;";
let findings = classify_sql(sql);
assert!(
findings.is_empty(),
"reviewed marker must suppress a dollar-quoted function containing DML: {findings:?}"
);
}
#[test]
fn tagged_dollar_quote_with_semicolons_is_one_statement() {
let sql = "CREATE FUNCTION foo() RETURNS void AS $func$ \
BEGIN UPDATE posts SET x = 1; END; $func$ LANGUAGE plpgsql;";
let findings = classify_sql(sql);
assert_eq!(
findings.len(),
1,
"tagged dollar-quote body with semicolons must not split: {findings:?}"
);
}
#[test]
fn split_statements_keeps_semicolon_in_string_literal_intact() {
let sql = "CREATE TABLE posts (\n \
id BIGSERIAL PRIMARY KEY,\n \
title TEXT NOT NULL DEFAULT 'hello; world',\n \
created_at TIMESTAMP NOT NULL DEFAULT NOW()\n\
);";
let statements = split_statements(sql);
assert_eq!(
statements.len(),
1,
"a semicolon inside a single-quoted literal must not split the statement: {statements:?}"
);
assert!(statements[0].contains("'hello; world'"));
}
#[test]
fn split_statements_handles_doubled_escaped_quote_in_literal() {
let sql = "INSERT INTO posts (title) VALUES ('it''s; still one literal');";
let statements = split_statements(sql);
assert_eq!(statements.len(), 1, "got: {statements:?}");
}
#[test]
fn split_statements_multiple_string_literals_with_semicolons() {
let sql = "CREATE TABLE posts (a TEXT DEFAULT 'x;y', b TEXT DEFAULT 'p;q');";
let statements = split_statements(sql);
assert_eq!(statements.len(), 1, "got: {statements:?}");
}
#[test]
fn drop_index_non_concurrent_is_potentially_blocking() {
let findings = classify_sql("DROP INDEX idx_posts_title;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::PotentiallyBlocking);
assert_eq!(findings[0].operation, "DROP INDEX (non-concurrent)");
}
#[test]
fn drop_index_with_concurrently_in_name_is_still_blocking() {
let findings = classify_sql("DROP INDEX idx_concurrently;");
assert_eq!(
findings.len(),
1,
"index named idx_concurrently must still be flagged as non-concurrent: {findings:?}"
);
assert_eq!(findings[0].operation, "DROP INDEX (non-concurrent)");
}
#[test]
fn drop_index_concurrently_is_safe_from_classifier() {
let findings = classify_sql("DROP INDEX CONCURRENTLY idx_posts_title;");
assert!(
findings
.iter()
.all(|f| f.risk != RiskLevel::PotentiallyBlocking
|| f.operation.contains("CONCURRENTLY")),
"DROP INDEX CONCURRENTLY must not produce a non-concurrent finding"
);
}
#[test]
fn drop_type_requires_manual_review() {
let findings = classify_sql("DROP TYPE status;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::ManualReview);
}
#[test]
fn drop_type_cascade_requires_manual_review() {
let findings = classify_sql("DROP TYPE status CASCADE;");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, RiskLevel::ManualReview);
}
#[test]
fn has_executable_sql_empty_string_is_false() {
assert!(!has_executable_sql(""));
}
#[test]
fn has_executable_sql_whitespace_only_is_false() {
assert!(!has_executable_sql(" \n\t\n "));
}
#[test]
fn has_executable_sql_line_comment_only_is_false() {
assert!(!has_executable_sql("-- nothing here\n-- just comments"));
}
#[test]
fn has_executable_sql_block_comment_only_is_false() {
assert!(!has_executable_sql("/* block comment only */"));
}
#[test]
fn has_executable_sql_real_sql_is_true() {
assert!(has_executable_sql("DROP TABLE posts;"));
}
#[test]
fn has_executable_sql_comment_plus_sql_is_true() {
assert!(has_executable_sql(
"-- undo the migration\nDROP TABLE posts;"
));
}
#[test]
fn has_executable_sql_no_trailing_semicolon_is_true() {
assert!(has_executable_sql("DROP TABLE posts"));
}
fn sqlite_finding(sql: &str, operation: &str) -> Option<SafetyFinding> {
classify_sql_for(DatabaseBackend::Sqlite, sql)
.into_iter()
.find(|f| f.operation == operation)
}
#[test]
fn sqlite_drop_index_is_safe() {
assert!(
classify_sql_for(DatabaseBackend::Sqlite, "DROP INDEX idx_posts_title;").is_empty(),
"SQLite DROP INDEX must not be flagged"
);
assert!(!classify_sql("DROP INDEX idx_posts_title;").is_empty());
}
#[test]
fn sqlite_create_index_advice_never_names_concurrently() {
let f = sqlite_finding(
"CREATE INDEX idx_posts_title ON posts (title);",
"CREATE INDEX",
)
.expect("SQLite still reports a blocking index build");
assert_eq!(f.risk, RiskLevel::PotentiallyBlocking);
assert!(
!f.next_action.to_lowercase().contains("concurrently"),
"SQLite has no CREATE INDEX CONCURRENTLY: {}",
f.next_action
);
assert!(
!f.why.to_lowercase().contains("postgres"),
"why must not cite Postgres on SQLite: {}",
f.why
);
}
#[test]
fn sqlite_create_index_concurrently_is_unsupported() {
let f = sqlite_finding(
"CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);",
"CREATE INDEX CONCURRENTLY (unsupported on SQLite)",
)
.expect("CONCURRENTLY is not SQLite syntax");
assert_eq!(f.risk, RiskLevel::Unsupported);
}
#[test]
fn sqlite_add_column_not_null_without_default_is_unsupported() {
let f = sqlite_finding(
"ALTER TABLE posts ADD COLUMN title TEXT NOT NULL;",
"ADD COLUMN NOT NULL (no default)",
)
.expect("finding expected");
assert_eq!(f.risk, RiskLevel::Unsupported);
assert_eq!(
classify_sql("ALTER TABLE posts ADD COLUMN title TEXT NOT NULL;")[0].risk,
RiskLevel::PotentiallyBlocking
);
}
#[test]
fn sqlite_add_column_not_null_with_constant_default_is_safe() {
assert!(
classify_sql_for(
DatabaseBackend::Sqlite,
"ALTER TABLE posts ADD COLUMN views INTEGER NOT NULL DEFAULT 0;"
)
.is_empty(),
"a constant default is the supported SQLite form"
);
}
#[test]
fn sqlite_add_column_with_expression_default_is_unsupported() {
for sql in [
"ALTER TABLE posts ADD COLUMN seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;",
"ALTER TABLE posts ADD COLUMN slug TEXT NOT NULL DEFAULT (lower(title));",
] {
let f = sqlite_finding(sql, "ADD COLUMN (non-constant default)")
.unwrap_or_else(|| panic!("finding expected for {sql}"));
assert_eq!(f.risk, RiskLevel::Unsupported, "{sql}");
}
}
#[test]
fn sqlite_alter_column_is_unsupported() {
for sql in [
"ALTER TABLE posts ALTER COLUMN title TYPE TEXT;",
"ALTER TABLE posts ALTER COLUMN title SET NOT NULL;",
] {
let f = sqlite_finding(sql, "ALTER COLUMN (unsupported on SQLite)")
.unwrap_or_else(|| panic!("finding expected for {sql}"));
assert_eq!(f.risk, RiskLevel::Unsupported, "{sql}");
assert!(
f.next_action.contains("rebuild"),
"must point at the table-rebuild procedure: {}",
f.next_action
);
}
}
#[test]
fn sqlite_add_column_inline_unique_is_unsupported() {
let f = sqlite_finding(
"ALTER TABLE posts ADD COLUMN slug TEXT UNIQUE;",
"ADD COLUMN UNIQUE (unsupported on SQLite)",
)
.expect("SQLite rejects ADD COLUMN with UNIQUE");
assert_eq!(f.risk, RiskLevel::Unsupported);
}
#[test]
fn sqlite_add_column_nullable_references_is_safe() {
assert!(
classify_sql_for(
DatabaseBackend::Sqlite,
"ALTER TABLE comments ADD COLUMN post_id INTEGER REFERENCES posts(id);"
)
.is_empty(),
"a nullable FK column is the supported SQLite form"
);
}
#[test]
fn sqlite_truncate_is_unsupported() {
let f = sqlite_finding("TRUNCATE TABLE posts;", "TRUNCATE (unsupported on SQLite)")
.expect("SQLite has no TRUNCATE statement");
assert_eq!(f.risk, RiskLevel::Unsupported);
assert!(f.next_action.contains("DELETE FROM"), "{}", f.next_action);
}
#[test]
fn sqlite_postgres_only_objects_are_unsupported() {
for sql in [
"CREATE SEQUENCE post_ids;",
"ALTER SEQUENCE post_ids RESTART WITH 1;",
"DROP SEQUENCE post_ids;",
"CREATE TYPE mood AS ENUM ('ok');",
"ALTER TYPE mood RENAME VALUE 'ok' TO 'fine';",
"DROP TYPE mood;",
"CREATE EXTENSION IF NOT EXISTS pgcrypto;",
"COMMENT ON TABLE posts IS 'hi';",
"CREATE MATERIALIZED VIEW mv AS SELECT 1;",
] {
let findings = classify_sql_for(DatabaseBackend::Sqlite, sql);
assert!(
findings.iter().any(|f| f.risk == RiskLevel::Unsupported),
"expected an Unsupported finding for {sql}, got {findings:?}"
);
}
}
#[test]
fn sqlite_drop_column_stays_destructive_with_sqlite_guidance() {
let f = sqlite_finding("ALTER TABLE posts DROP COLUMN title;", "DROP COLUMN")
.expect("finding expected");
assert_eq!(f.risk, RiskLevel::Destructive);
assert!(
f.next_action.contains("DROP INDEX"),
"SQLite refuses to drop an indexed column: {}",
f.next_action
);
}
#[test]
fn sqlite_unsupported_blocks_the_deploy_gate() {
let findings = classify_sql_for(
DatabaseBackend::Sqlite,
"ALTER TABLE posts ALTER COLUMN a TYPE TEXT;",
);
assert!(has_unsafe_findings(&findings));
assert!(!is_safe(&findings));
}
#[test]
fn unsupported_is_the_highest_risk_level() {
assert!(RiskLevel::ManualReview < RiskLevel::Unsupported);
}
#[test]
fn classify_sql_still_classifies_as_postgres() {
let sql = "ALTER TABLE posts ADD COLUMN title TEXT NOT NULL;\nDROP INDEX idx_posts_a;\n\
CREATE INDEX idx_posts_b ON posts (b);";
let bare = classify_sql(sql);
let explicit = classify_sql_for(DatabaseBackend::Postgres, sql);
assert_eq!(bare.len(), explicit.len());
for (a, b) in bare.iter().zip(explicit.iter()) {
assert_eq!(a.operation, b.operation);
assert_eq!(a.risk, b.risk);
assert_eq!(a.why, b.why);
assert_eq!(a.next_action, b.next_action);
}
}
fn sqlite_ops(sql: &str) -> Vec<String> {
classify_sql_for(DatabaseBackend::Sqlite, sql)
.into_iter()
.map(|f| f.operation)
.collect()
}
#[test]
fn sqlite_add_column_not_null_default_null_is_unsupported() {
let f = sqlite_finding(
"ALTER TABLE posts ADD COLUMN title TEXT NOT NULL DEFAULT NULL;",
"ADD COLUMN NOT NULL (no default)",
)
.expect("finding expected");
assert_eq!(f.risk, RiskLevel::Unsupported);
}
#[test]
fn sqlite_add_column_bare_function_default_is_unsupported() {
for sql in [
"ALTER TABLE posts ADD COLUMN a TEXT NOT NULL DEFAULT now();",
"ALTER TABLE posts ADD COLUMN b TEXT NOT NULL DEFAULT gen_random_uuid();",
] {
let f = sqlite_finding(sql, "ADD COLUMN (non-constant default)")
.unwrap_or_else(|| panic!("finding expected for {sql}"));
assert_eq!(f.risk, RiskLevel::Unsupported, "{sql}");
}
}
#[test]
fn sqlite_add_column_parenthesized_literal_default_is_accepted() {
for sql in [
"ALTER TABLE posts ADD COLUMN a TEXT NOT NULL DEFAULT ('draft');",
"ALTER TABLE posts ADD COLUMN b INTEGER NOT NULL DEFAULT (0);",
"ALTER TABLE posts ADD COLUMN c INTEGER NOT NULL DEFAULT -1;",
"ALTER TABLE posts ADD COLUMN d TEXT NOT NULL DEFAULT 'a b';",
] {
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql).is_empty(),
"{sql} must be accepted, got {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn sqlite_reads_a_default_with_no_space_before_the_value() {
assert!(
classify_sql_for(
DatabaseBackend::Sqlite,
"ALTER TABLE posts ADD COLUMN views INTEGER NOT NULL DEFAULT(0);"
)
.is_empty(),
"got {:?}",
sqlite_ops("ALTER TABLE posts ADD COLUMN views INTEGER NOT NULL DEFAULT(0);")
);
let f = sqlite_finding(
"ALTER TABLE posts ADD COLUMN defaulted TEXT NOT NULL;",
"ADD COLUMN NOT NULL (no default)",
)
.expect("finding expected");
assert_eq!(f.risk, RiskLevel::Unsupported);
}
#[test]
fn sqlite_numeric_defaults_including_exponent_form_are_accepted() {
for sql in [
"ALTER TABLE s ADD COLUMN a REAL DEFAULT 1e-3;",
"ALTER TABLE s ADD COLUMN b REAL DEFAULT -1.5E+10;",
"ALTER TABLE s ADD COLUMN c REAL DEFAULT .5;",
"ALTER TABLE s ADD COLUMN d INTEGER DEFAULT 0x1f;",
"ALTER TABLE s ADD COLUMN e REAL DEFAULT (1e-3);",
] {
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql).is_empty(),
"{sql} is a numeric literal, got {:?}",
sqlite_ops(sql)
);
}
let expr = "ALTER TABLE s ADD COLUMN f REAL DEFAULT 1e-3+1;";
assert!(
classify_sql_for(DatabaseBackend::Sqlite, expr)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{expr} -> {:?}",
sqlite_ops(expr)
);
}
#[test]
fn sqlite_add_column_parenthesized_expression_default_is_unsupported() {
for sql in [
"ALTER TABLE posts ADD COLUMN a TEXT NOT NULL DEFAULT (1+2);",
"ALTER TABLE posts ADD COLUMN b TEXT NOT NULL DEFAULT (abs(-1));",
"ALTER TABLE posts ADD COLUMN c TEXT NOT NULL DEFAULT (datetime('now'));",
] {
let f = sqlite_finding(sql, "ADD COLUMN (non-constant default)")
.unwrap_or_else(|| panic!("finding expected for {sql}"));
assert_eq!(f.risk, RiskLevel::Unsupported, "{sql}");
}
}
#[test]
fn sqlite_add_column_references_applies_without_a_foreign_keys_pragma() {
let sql = "ALTER TABLE comments ADD COLUMN post_id INTEGER NOT NULL DEFAULT 0 \
REFERENCES posts(id);";
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql).is_empty(),
"got {:?}",
sqlite_ops(sql)
);
}
#[test]
fn sqlite_fk_action_set_default_is_not_a_column_default() {
let sql = "ALTER TABLE t ADD COLUMN c INTEGER REFERENCES u(id) \
ON UPDATE SET DEFAULT ON DELETE CASCADE;";
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql).is_empty(),
"got {:?}",
sqlite_ops(sql)
);
}
#[test]
fn sqlite_unique_inside_a_string_literal_is_not_a_constraint() {
let sql = "ALTER TABLE t ADD COLUMN d TEXT DEFAULT 'not unique yet';";
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql).is_empty(),
"got {:?}",
sqlite_ops(sql)
);
}
#[test]
fn sqlite_add_column_generated_stored_is_unsupported() {
let f = sqlite_finding(
"ALTER TABLE posts ADD COLUMN n INT GENERATED ALWAYS AS (id + 1) STORED;",
"ADD COLUMN GENERATED … STORED (unsupported on SQLite)",
)
.expect("finding expected");
assert_eq!(f.risk, RiskLevel::Unsupported);
assert!(
classify_sql_for(
DatabaseBackend::Sqlite,
"ALTER TABLE posts ADD COLUMN n INT GENERATED ALWAYS AS (id + 1) VIRTUAL;"
)
.is_empty()
);
}
#[test]
fn sqlite_row_dependent_add_column_rules_skip_a_table_this_migration_creates() {
let sql = "CREATE TABLE posts (id INTEGER PRIMARY KEY);\n\
ALTER TABLE posts ADD COLUMN a INT NOT NULL;\n\
ALTER TABLE posts ADD COLUMN b TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;\n\
ALTER TABLE posts ADD COLUMN c INT GENERATED ALWAYS AS (id + 1) STORED;";
assert!(
!classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"got {:?}",
sqlite_ops(sql)
);
let existing = "ALTER TABLE posts ADD COLUMN a INT NOT NULL;\n\
ALTER TABLE posts ADD COLUMN b TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;\n\
ALTER TABLE posts ADD COLUMN c INT GENERATED ALWAYS AS (id + 1) STORED;";
assert_eq!(
classify_sql_for(DatabaseBackend::Sqlite, existing)
.iter()
.filter(|f| f.risk == RiskLevel::Unsupported)
.count(),
3,
"got {:?}",
sqlite_ops(existing)
);
}
#[test]
fn sqlite_concurrently_must_be_the_keyword_not_a_name_prefix() {
for sql in [
"DROP INDEX concurrently_old_idx;",
"CREATE INDEX concurrently_idx ON posts (x);",
] {
assert!(
!classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{sql} names an index, it does not use CONCURRENTLY: {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn sqlite_rejects_every_alter_table_subcommand_outside_its_grammar() {
for sql in [
"ALTER TABLE t ADD CONSTRAINT ck CHECK (id > 0);",
"ALTER TABLE t DROP CONSTRAINT ck;",
"ALTER TABLE t SET SCHEMA other;",
"ALTER TABLE t OWNER TO app;",
] {
let findings = classify_sql_for(DatabaseBackend::Sqlite, sql);
assert!(
findings.iter().any(|f| f.risk == RiskLevel::Unsupported),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
for sql in [
"ALTER TABLE t ADD COLUMN c TEXT;",
"ALTER TABLE t DROP COLUMN c;",
"ALTER TABLE t RENAME COLUMN a TO b;",
"ALTER TABLE t RENAME TO u;",
] {
assert!(
!classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn sqlite_unique_and_primary_key_are_rejected_even_on_a_fresh_table() {
let sql = "CREATE TABLE posts (id INTEGER PRIMARY KEY);\n\
ALTER TABLE posts ADD COLUMN a TEXT UNIQUE;\n\
ALTER TABLE posts ADD COLUMN b INTEGER PRIMARY KEY;";
let ops = sqlite_ops(sql);
assert!(
ops.contains(&"ADD COLUMN UNIQUE (unsupported on SQLite)".to_owned()),
"got {ops:?}"
);
assert!(
ops.contains(&"ADD COLUMN PRIMARY KEY (unsupported on SQLite)".to_owned()),
"got {ops:?}"
);
}
#[test]
fn sqlite_bare_add_and_drop_forms_get_the_same_rules() {
let f = sqlite_finding("ALTER TABLE posts DROP title;", "DROP COLUMN")
.expect("a bare DROP is still destructive");
assert_eq!(f.risk, RiskLevel::Destructive);
let f = sqlite_finding(
"ALTER TABLE posts ADD title TEXT NOT NULL;",
"ADD COLUMN NOT NULL (no default)",
)
.expect("a bare ADD still hits the NOT NULL rule");
assert_eq!(f.risk, RiskLevel::Unsupported);
for sql in [
"ALTER TABLE t ADD CONSTRAINT ck CHECK (id > 0);",
"ALTER TABLE t DROP CONSTRAINT ck;",
] {
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn sqlite_writing_cte_is_caught_through_formatting_whitespace() {
let sql = "WITH removed AS ( DELETE FROM posts RETURNING id) SELECT * FROM removed;";
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"got {:?}",
sqlite_ops(sql)
);
}
#[test]
fn sqlite_bare_rename_is_a_rename_column() {
let f = sqlite_finding(
"ALTER TABLE posts RENAME title TO headline;",
"RENAME COLUMN",
)
.expect("a bare column rename must be flagged");
assert_eq!(f.risk, RiskLevel::Irreversible);
}
#[test]
fn sqlite_bare_rename_is_not_a_rename_table() {
let ops = sqlite_ops("ALTER TABLE posts RENAME title TO headline;");
assert!(
!ops.iter().any(|o| o.starts_with("RENAME TABLE")),
"{ops:?}"
);
assert!(sqlite_finding("ALTER TABLE posts RENAME TO articles;", "RENAME TABLE").is_some());
}
#[test]
fn alter_table_subcommands_ignores_a_comma_in_a_string_literal() {
let subs =
alter_table_subcommands("alter table posts add column l text default 'a,b'", true);
assert_eq!(subs, ["add column l text default 'a,b'"]);
let subs = alter_table_subcommands(
"alter table posts add column l text default 'it''s,x'",
true,
);
assert_eq!(subs.len(), 1, "{subs:?}");
let subs = alter_table_subcommands("alter table posts drop column \"a,b\"", true);
assert_eq!(subs.len(), 1, "{subs:?}");
}
#[test]
fn alter_table_subcommands_splits_after_an_escaped_quote() {
let subs = alter_table_subcommands(
"alter table posts add column a text default 'it''s', add column b text",
true,
);
assert_eq!(
subs,
["add column a text default 'it''s'", "add column b text"]
);
let subs = alter_table_subcommands(
"alter table posts add column a text default 'it''s,x'",
true,
);
assert_eq!(subs, ["add column a text default 'it''s,x'"]);
}
#[test]
fn alter_table_subcommands_ignores_a_comma_in_backtick_and_bracket_names() {
for sql in [
"alter table posts drop `a,b`",
"alter table posts drop [a,b]",
] {
assert_eq!(alter_table_subcommands(sql, true).len(), 1, "{sql}");
}
}
#[test]
fn sqlite_rewrite_keeps_the_head_for_odd_comma_spacing() {
assert_eq!(
canonical_sqlite_alter("alter table posts add a text,add b text"),
"alter table posts add column a text, add column b text"
);
assert_eq!(
canonical_sqlite_alter("alter table posts add a text , add b text"),
"alter table posts add column a text, add column b text"
);
}
#[test]
fn sqlite_rename_edge_spellings() {
for sql in [
"ALTER TABLE posts RENAME COLUMN a TO b;",
"ALTER TABLE posts RENAME \"to\" TO headline;",
"ALTER TABLE posts RENAME \"a to b\" TO c;",
] {
assert!(
sqlite_finding(sql, "RENAME COLUMN").is_some(),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
let sql = "ALTER TABLE posts RENAME TO \"a to b\";";
assert!(sqlite_finding(sql, "RENAME TABLE").is_some());
assert!(sqlite_finding(sql, "RENAME COLUMN").is_none());
}
#[test]
fn postgres_dollar_quote_cannot_hide_a_drop_column() {
let f = classify_sql_for(
DatabaseBackend::Postgres,
"ALTER TABLE t ADD COLUMN a text DEFAULT $$it's$$, DROP COLUMN b;",
);
assert!(f.iter().any(|f| f.operation == "DROP COLUMN"), "{f:?}");
}
#[test]
fn postgres_alter_classification_is_unchanged() {
let ops = |sql: &str| -> Vec<String> {
classify_sql_for(DatabaseBackend::Postgres, sql)
.into_iter()
.map(|f| f.operation)
.collect()
};
let quoted = ops("ALTER TABLE posts ADD COLUMN l text DEFAULT 'a,b';");
assert!(
!quoted.iter().any(|o| o.starts_with("Multi-action")),
"{quoted:?}"
);
let bare = ops("ALTER TABLE posts RENAME title TO headline;");
assert!(!bare.iter().any(|o| o == "RENAME COLUMN"), "{bare:?}");
}
#[test]
fn sqlite_comma_in_a_default_literal_is_not_multi_action() {
let sql = "ALTER TABLE posts ADD COLUMN label TEXT DEFAULT 'a,b';";
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql).is_empty(),
"{:?}",
sqlite_ops(sql)
);
assert!(
sqlite_finding(
"ALTER TABLE posts ADD COLUMN a TEXT DEFAULT 'x,y', ADD COLUMN b TEXT;",
"Multi-action ALTER TABLE (unsupported on SQLite)",
)
.is_some()
);
}
#[test]
fn sqlite_rejects_a_multi_action_alter_table() {
let f = sqlite_finding(
"ALTER TABLE posts ADD COLUMN a TEXT, ADD COLUMN b TEXT;",
"Multi-action ALTER TABLE (unsupported on SQLite)",
)
.expect("finding expected");
assert_eq!(f.risk, RiskLevel::Unsupported);
assert!(
classify_sql_for(
DatabaseBackend::Sqlite,
"ALTER TABLE posts ADD COLUMN a TEXT;"
)
.is_empty()
);
assert!(classify_sql("ALTER TABLE posts ADD COLUMN a TEXT, ADD COLUMN b TEXT;").is_empty());
}
#[test]
fn sqlite_alter_table_never_falls_through_to_manual_review() {
for sql in [
"ALTER TABLE t ALTER COLUMN c SET NOT NULL;",
"ALTER TABLE t ADD COLUMN c INTEGER PRIMARY KEY;",
"ALTER TABLE t VALIDATE CONSTRAINT ck;",
] {
assert!(
!sqlite_ops(sql).contains(&"Unclassified ALTER TABLE".to_owned()),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn sqlite_rejects_postgres_only_index_and_table_clauses() {
for sql in [
"CREATE INDEX i ON posts USING btree (a);",
"CREATE UNIQUE INDEX i ON posts (a) INCLUDE (b);",
"CREATE INDEX i ON posts (a) WITH (fillfactor=70);",
"CREATE TABLE p (id int) PARTITION BY RANGE (id);",
"CREATE TABLE r (id int GENERATED BY DEFAULT AS IDENTITY);",
] {
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
let ok = "CREATE INDEX i ON posts (title COLLATE NOCASE DESC) WHERE deleted_at IS NULL;";
assert!(
!classify_sql_for(DatabaseBackend::Sqlite, ok)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{ok} -> {:?}",
sqlite_ops(ok)
);
}
#[test]
fn sqlite_unsupported_clauses_ignore_string_literal_contents() {
for sql in [
"INSERT INTO audit_log(message) VALUES ('waiting for update');",
"INSERT INTO audit_log(message) VALUES ('on conflict on constraint x');",
] {
assert!(
!classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{sql} is ordinary data, got {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn sqlite_rejects_postgres_only_dml_forms() {
for sql in [
"MERGE INTO t USING u ON t.id = u.id WHEN MATCHED THEN UPDATE SET a = 1;",
"SELECT id FROM t FOR UPDATE;",
"INSERT INTO t (a) VALUES (1) ON CONFLICT ON CONSTRAINT t_pkey DO NOTHING;",
"WITH d AS (DELETE FROM t RETURNING id) SELECT * FROM d;",
] {
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn sqlite_cannot_drop_an_implicit_index() {
for sql in [
"DROP INDEX sqlite_autoindex_posts_1;",
"DROP INDEX IF EXISTS sqlite_autoindex_posts_1;",
] {
let findings = classify_sql_for(DatabaseBackend::Sqlite, sql);
assert!(
findings.iter().any(|f| f.risk == RiskLevel::Unsupported),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn sqlite_covers_every_postgres_only_object_statement() {
for sql in [
"GRANT SELECT ON posts TO app;",
"REVOKE SELECT ON posts FROM app;",
"DROP EXTENSION pgcrypto;",
"DROP MATERIALIZED VIEW mv;",
"REFRESH MATERIALIZED VIEW mv;",
"CREATE UNIQUE INDEX CONCURRENTLY i ON posts (a);",
"DROP INDEX CONCURRENTLY i;",
"ALTER SEQUENCE s RESTART;",
] {
assert!(
classify_sql_for(DatabaseBackend::Sqlite, sql)
.iter()
.any(|f| f.risk == RiskLevel::Unsupported),
"{sql} -> {:?}",
sqlite_ops(sql)
);
}
}
#[test]
fn finding_prose_has_no_stray_whitespace_runs() {
let samples = [
"ALTER TABLE t ALTER COLUMN c TYPE TEXT;",
"ALTER TABLE t ADD COLUMN c TEXT NOT NULL;",
"ALTER TABLE t ADD COLUMN c TEXT UNIQUE;",
"ALTER TABLE t DROP COLUMN c;",
"TRUNCATE TABLE t;",
"CREATE INDEX i ON t (a);",
"CREATE SEQUENCE s;",
"CREATE TYPE m AS ENUM ('a');",
"CREATE EXTENSION pgcrypto;",
"COMMENT ON TABLE t IS 'x';",
"CREATE MATERIALIZED VIEW mv AS SELECT 1;",
"GRANT SELECT ON t TO app;",
"MERGE INTO t USING u ON t.id = u.id WHEN MATCHED THEN UPDATE SET a = 1;",
"CREATE INDEX i ON t USING btree (a);",
"CREATE TABLE p (id int) PARTITION BY RANGE (id);",
"SELECT id FROM t FOR UPDATE;",
"WITH d AS (DELETE FROM t RETURNING id) SELECT * FROM d;",
"DROP INDEX sqlite_autoindex_t_1;",
"ALTER TABLE t ADD COLUMN n INT GENERATED ALWAYS AS (id + 1) STORED;",
];
for backend in [DatabaseBackend::Postgres, DatabaseBackend::Sqlite] {
for sql in samples {
for f in classify_sql_for(backend, sql) {
assert!(!f.why.contains(" "), "{sql}: why = {:?}", f.why);
assert!(
!f.next_action.contains(" "),
"{sql}: next_action = {:?}",
f.next_action
);
}
}
}
}
}