use super::finding::{Finding, LintCategory, LintReport, LintSeverity};
use super::statement::SqlStatement;
#[must_use]
pub fn classify_statements(statements: &[SqlStatement]) -> LintReport {
let mut findings = Vec::new();
for (index, statement) in statements.iter().enumerate() {
let upper = statement.sql.to_uppercase();
classify_one(&upper, index, &mut findings);
}
LintReport {
findings,
statements: statements.len(),
}
}
#[must_use]
pub fn classify(script: &str) -> LintReport {
let statements = SqlStatement::split(script);
classify_statements(&statements)
}
fn classify_one(upper: &str, index: usize, findings: &mut Vec<Finding>) {
if contains_word(upper, "DROP TABLE") || contains_word(upper, "DROP COLUMN") {
findings.push(Finding {
severity: LintSeverity::Critical,
category: LintCategory::DestructiveDrop,
message: "DROP removes data irreversibly".to_owned(),
fix: "back up the table/column, verify no dependencies, and apply \
outside a rolling upgrade"
.to_owned(),
statement: index,
});
} else if contains_word(upper, "DROP INDEX")
|| contains_word(upper, "DROP SCHEMA")
|| contains_word(upper, "DROP CONSTRAINT")
|| contains_word(upper, "DROP VIEW")
|| contains_word(upper, "DROP TYPE")
|| contains_word(upper, "DROP MATERIALIZED")
{
findings.push(Finding {
severity: LintSeverity::Warning,
category: LintCategory::DestructiveDrop,
message: "DROP removes a schema object; verify no code depends on it".to_owned(),
fix: "confirm no queries/code reference the dropped object before \
applying"
.to_owned(),
statement: index,
});
}
if (contains_word(upper, "ADD COLUMN") || contains_word(upper, "ADD"))
&& contains_word(upper, "NOT NULL")
&& !contains_word(upper, "DEFAULT")
{
findings.push(Finding {
severity: LintSeverity::Critical,
category: LintCategory::AddNotNullNoDefault,
message: "ADD COLUMN with NOT NULL and no DEFAULT fails on existing \
rows and breaks rolling upgrades"
.to_owned(),
fix: "add a DEFAULT (or make the column nullable, backfill, then \
SET NOT NULL in a later migration)"
.to_owned(),
statement: index,
});
}
if contains_word(upper, "SET NOT NULL") && !contains_word(upper, "ADD") {
findings.push(Finding {
severity: LintSeverity::Warning,
category: LintCategory::UnsafeNotNull,
message: "SET NOT NULL rewrites the table and fails on existing NULL \
values"
.to_owned(),
fix: "backfill NULLs to a real value first, then SET NOT NULL (or \
add a CHECK constraint NOT VALID, validate, then SET NOT NULL)"
.to_owned(),
statement: index,
});
}
if contains_word(upper, "ALTER COLUMN") && contains_word(upper, "TYPE") {
findings.push(Finding {
severity: LintSeverity::Warning,
category: LintCategory::TypeNarrowing,
message: "ALTER COLUMN TYPE may narrow the value domain or rewrite \
the table"
.to_owned(),
fix: "review the new type for value loss; prefer a multi-step \
widen (add column, copy, drop old) for risky changes"
.to_owned(),
statement: index,
});
}
if contains_word(upper, "RENAME TO") || contains_word(upper, "RENAME COLUMN") {
findings.push(Finding {
severity: LintSeverity::Warning,
category: LintCategory::DangerousRename,
message: "RENAME breaks code referencing the old name and complicates \
rolling upgrades"
.to_owned(),
fix: "deploy code that tolerates both names first, or rename during \
a coordinated downtime"
.to_owned(),
statement: index,
});
}
if (contains_word(upper, "CREATE INDEX") || contains_word(upper, "CREATE UNIQUE INDEX"))
&& !contains_word(upper, "CONCURRENTLY")
{
findings.push(Finding {
severity: LintSeverity::Warning,
category: LintCategory::BlockingIndex,
message: "CREATE INDEX without CONCURRENTLY blocks writes for the \
duration of the build"
.to_owned(),
fix: "use CREATE INDEX CONCURRENTLY (cannot run inside a transaction \
block — apply as a standalone migration)"
.to_owned(),
statement: index,
});
}
}
fn contains_word(haystack: &str, needle: &str) -> bool {
let Some(start) = haystack.find(needle) else {
return false;
};
let end = start + needle.len();
let before_ok = start == 0 || !is_ident_char(haystack.as_bytes()[start - 1]);
let after_ok = end >= haystack.len() || !is_ident_char(haystack.as_bytes()[end]);
before_ok && after_ok
}
fn is_ident_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'"'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_statement_has_no_findings() {
let report = classify("CREATE TABLE t (id int);");
assert!(report.is_clean());
assert_eq!(report.statements, 1);
}
#[test]
fn flags_drop_table_as_critical() {
let report = classify("DROP TABLE users;");
assert!(report.has_critical());
assert_eq!(report.findings[0].category, LintCategory::DestructiveDrop);
assert_eq!(report.findings[0].severity, LintSeverity::Critical);
}
#[test]
fn flags_drop_index_as_warning() {
let report = classify("DROP INDEX idx;");
assert!(!report.has_critical());
assert_eq!(report.count_at(LintSeverity::Warning), 1);
}
#[test]
fn flags_add_not_null_without_default_as_critical() {
let report = classify("ALTER TABLE t ADD COLUMN c int NOT NULL;");
assert!(report.has_critical());
assert_eq!(
report.findings[0].category,
LintCategory::AddNotNullNoDefault
);
}
#[test]
fn add_not_null_with_default_is_clean() {
let report = classify("ALTER TABLE t ADD COLUMN c int NOT NULL DEFAULT 0;");
assert!(
report.is_clean(),
"NOT NULL with a DEFAULT is safe; report was {report:?}"
);
}
#[test]
fn flags_set_not_null_as_warning() {
let report = classify("ALTER TABLE t ALTER COLUMN c SET NOT NULL;");
assert_eq!(report.count_at(LintSeverity::Warning), 1);
assert_eq!(report.findings[0].category, LintCategory::UnsafeNotNull);
}
#[test]
fn flags_create_index_without_concurrently() {
let report = classify("CREATE INDEX idx ON t (c);");
assert_eq!(report.count_at(LintSeverity::Warning), 1);
assert_eq!(report.findings[0].category, LintCategory::BlockingIndex);
}
#[test]
fn create_index_concurrently_is_clean() {
let report = classify("CREATE INDEX CONCURRENTLY idx ON t (c);");
assert!(report.is_clean());
}
#[test]
fn flags_rename() {
let report = classify("ALTER TABLE t RENAME TO t2;");
assert_eq!(report.count_at(LintSeverity::Warning), 1);
assert_eq!(report.findings[0].category, LintCategory::DangerousRename);
}
#[test]
fn flags_alter_column_type() {
let report = classify("ALTER TABLE t ALTER COLUMN c TYPE bigint;");
assert_eq!(report.count_at(LintSeverity::Warning), 1);
assert_eq!(report.findings[0].category, LintCategory::TypeNarrowing);
}
#[test]
fn word_boundary_prevents_false_positive() {
let report = classify("CREATE TABLE dropdown (id int);");
assert!(report.is_clean());
}
#[test]
fn multiple_findings_across_statements() {
let report = classify("DROP TABLE a; CREATE INDEX i ON b (c);");
assert_eq!(report.statements, 2);
assert!(report.has_critical());
assert_eq!(report.count_at(LintSeverity::Warning), 1);
}
#[test]
fn hostile_input_does_not_panic() {
let _ = classify("'''''''\"\"\"\"DROP '");
let _ = classify("/* /* nested */ DROP TABLE");
let _ = classify(";");
}
}