use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum RiskLevel {
Safe,
PotentiallyBlocking,
Destructive,
Irreversible,
DataBackfill,
ManualReview,
}
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"),
}
}
}
#[derive(Debug, Clone)]
pub struct SafetyFinding {
pub operation: String,
pub risk: RiskLevel,
pub why: &'static str,
pub next_action: &'static str,
}
pub fn classify_sql(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(&normalized);
findings.retain(|f| {
f.operation != "CREATE INDEX (non-concurrent)"
|| 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) }
}
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) -> 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 start = 0;
for (i, c) in subcommands.char_indices() {
match c {
'(' => 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(subcommand: &str) -> bool {
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 has_inline_unique_constraint(subcommand: &str) -> bool {
subcommand.contains(" unique ") || subcommand.ends_with(" unique")
}
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
}
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(normalized: &str) -> Vec<SafetyFinding> {
if normalized.is_empty() {
return vec![];
}
let mut findings = Vec::new();
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: "Removes a column and its data. Old replicas that SELECT or INSERT this column \
will error until they restart.",
next_action: "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 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) {
if subcommand.starts_with("add column ") && subcommand.contains("not null") {
let has_default = 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: RiskLevel::PotentiallyBlocking,
why: "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: "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 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) {
if !subcommand.starts_with("add column ") {
continue;
}
if has_inline_unique_constraint(subcommand) {
findings.push(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 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);
let all_known = subcommands.iter().all(|s| is_known_alter_subcommand(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: "CREATE INDEX (non-concurrent)".to_owned(),
risk: RiskLevel::PotentiallyBlocking,
why: "Non-concurrent index creation holds an exclusive table lock for the entire \
build, blocking all reads and writes.",
next_action: "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 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 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 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"));
}
}