1use super::finding::{Finding, LintCategory, LintReport, LintSeverity};
22use super::statement::SqlStatement;
23
24#[must_use]
29pub fn classify_statements(statements: &[SqlStatement]) -> LintReport {
30 let mut findings = Vec::new();
31 for (index, statement) in statements.iter().enumerate() {
32 let upper = statement.sql.to_uppercase();
33 classify_one(&upper, index, &mut findings);
34 }
35 LintReport {
36 findings,
37 statements: statements.len(),
38 }
39}
40
41#[must_use]
43pub fn classify(script: &str) -> LintReport {
44 let statements = SqlStatement::split(script);
45 classify_statements(&statements)
46}
47
48fn classify_one(upper: &str, index: usize, findings: &mut Vec<Finding>) {
49 if contains_word(upper, "DROP TABLE") || contains_word(upper, "DROP COLUMN") {
52 findings.push(Finding {
53 severity: LintSeverity::Critical,
54 category: LintCategory::DestructiveDrop,
55 message: "DROP removes data irreversibly".to_owned(),
56 fix: "back up the table/column, verify no dependencies, and apply \
57 outside a rolling upgrade"
58 .to_owned(),
59 statement: index,
60 });
61 } else if contains_word(upper, "DROP INDEX")
62 || contains_word(upper, "DROP SCHEMA")
63 || contains_word(upper, "DROP CONSTRAINT")
64 || contains_word(upper, "DROP VIEW")
65 || contains_word(upper, "DROP TYPE")
66 || contains_word(upper, "DROP MATERIALIZED")
67 {
68 findings.push(Finding {
69 severity: LintSeverity::Warning,
70 category: LintCategory::DestructiveDrop,
71 message: "DROP removes a schema object; verify no code depends on it".to_owned(),
72 fix: "confirm no queries/code reference the dropped object before \
73 applying"
74 .to_owned(),
75 statement: index,
76 });
77 }
78
79 if (contains_word(upper, "ADD COLUMN") || contains_word(upper, "ADD"))
82 && contains_word(upper, "NOT NULL")
83 && !contains_word(upper, "DEFAULT")
84 {
85 findings.push(Finding {
86 severity: LintSeverity::Critical,
87 category: LintCategory::AddNotNullNoDefault,
88 message: "ADD COLUMN with NOT NULL and no DEFAULT fails on existing \
89 rows and breaks rolling upgrades"
90 .to_owned(),
91 fix: "add a DEFAULT (or make the column nullable, backfill, then \
92 SET NOT NULL in a later migration)"
93 .to_owned(),
94 statement: index,
95 });
96 }
97
98 if contains_word(upper, "SET NOT NULL") && !contains_word(upper, "ADD") {
102 findings.push(Finding {
103 severity: LintSeverity::Warning,
104 category: LintCategory::UnsafeNotNull,
105 message: "SET NOT NULL rewrites the table and fails on existing NULL \
106 values"
107 .to_owned(),
108 fix: "backfill NULLs to a real value first, then SET NOT NULL (or \
109 add a CHECK constraint NOT VALID, validate, then SET NOT NULL)"
110 .to_owned(),
111 statement: index,
112 });
113 }
114
115 if contains_word(upper, "ALTER COLUMN") && contains_word(upper, "TYPE") {
117 findings.push(Finding {
118 severity: LintSeverity::Warning,
119 category: LintCategory::TypeNarrowing,
120 message: "ALTER COLUMN TYPE may narrow the value domain or rewrite \
121 the table"
122 .to_owned(),
123 fix: "review the new type for value loss; prefer a multi-step \
124 widen (add column, copy, drop old) for risky changes"
125 .to_owned(),
126 statement: index,
127 });
128 }
129
130 if contains_word(upper, "RENAME TO") || contains_word(upper, "RENAME COLUMN") {
132 findings.push(Finding {
133 severity: LintSeverity::Warning,
134 category: LintCategory::DangerousRename,
135 message: "RENAME breaks code referencing the old name and complicates \
136 rolling upgrades"
137 .to_owned(),
138 fix: "deploy code that tolerates both names first, or rename during \
139 a coordinated downtime"
140 .to_owned(),
141 statement: index,
142 });
143 }
144
145 if (contains_word(upper, "CREATE INDEX") || contains_word(upper, "CREATE UNIQUE INDEX"))
147 && !contains_word(upper, "CONCURRENTLY")
148 {
149 findings.push(Finding {
150 severity: LintSeverity::Warning,
151 category: LintCategory::BlockingIndex,
152 message: "CREATE INDEX without CONCURRENTLY blocks writes for the \
153 duration of the build"
154 .to_owned(),
155 fix: "use CREATE INDEX CONCURRENTLY (cannot run inside a transaction \
156 block — apply as a standalone migration)"
157 .to_owned(),
158 statement: index,
159 });
160 }
161}
162
163fn contains_word(haystack: &str, needle: &str) -> bool {
169 let Some(start) = haystack.find(needle) else {
170 return false;
171 };
172 let end = start + needle.len();
173 let before_ok = start == 0 || !is_ident_char(haystack.as_bytes()[start - 1]);
174 let after_ok = end >= haystack.len() || !is_ident_char(haystack.as_bytes()[end]);
175 before_ok && after_ok
176}
177
178fn is_ident_char(byte: u8) -> bool {
179 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'"'
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn clean_statement_has_no_findings() {
188 let report = classify("CREATE TABLE t (id int);");
189 assert!(report.is_clean());
190 assert_eq!(report.statements, 1);
191 }
192
193 #[test]
194 fn flags_drop_table_as_critical() {
195 let report = classify("DROP TABLE users;");
196 assert!(report.has_critical());
197 assert_eq!(report.findings[0].category, LintCategory::DestructiveDrop);
198 assert_eq!(report.findings[0].severity, LintSeverity::Critical);
199 }
200
201 #[test]
202 fn flags_drop_index_as_warning() {
203 let report = classify("DROP INDEX idx;");
204 assert!(!report.has_critical());
205 assert_eq!(report.count_at(LintSeverity::Warning), 1);
206 }
207
208 #[test]
209 fn flags_add_not_null_without_default_as_critical() {
210 let report = classify("ALTER TABLE t ADD COLUMN c int NOT NULL;");
211 assert!(report.has_critical());
212 assert_eq!(
213 report.findings[0].category,
214 LintCategory::AddNotNullNoDefault
215 );
216 }
217
218 #[test]
219 fn add_not_null_with_default_is_clean() {
220 let report = classify("ALTER TABLE t ADD COLUMN c int NOT NULL DEFAULT 0;");
221 assert!(
222 report.is_clean(),
223 "NOT NULL with a DEFAULT is safe; report was {report:?}"
224 );
225 }
226
227 #[test]
228 fn flags_set_not_null_as_warning() {
229 let report = classify("ALTER TABLE t ALTER COLUMN c SET NOT NULL;");
230 assert_eq!(report.count_at(LintSeverity::Warning), 1);
231 assert_eq!(report.findings[0].category, LintCategory::UnsafeNotNull);
232 }
233
234 #[test]
235 fn flags_create_index_without_concurrently() {
236 let report = classify("CREATE INDEX idx ON t (c);");
237 assert_eq!(report.count_at(LintSeverity::Warning), 1);
238 assert_eq!(report.findings[0].category, LintCategory::BlockingIndex);
239 }
240
241 #[test]
242 fn create_index_concurrently_is_clean() {
243 let report = classify("CREATE INDEX CONCURRENTLY idx ON t (c);");
244 assert!(report.is_clean());
245 }
246
247 #[test]
248 fn flags_rename() {
249 let report = classify("ALTER TABLE t RENAME TO t2;");
250 assert_eq!(report.count_at(LintSeverity::Warning), 1);
251 assert_eq!(report.findings[0].category, LintCategory::DangerousRename);
252 }
253
254 #[test]
255 fn flags_alter_column_type() {
256 let report = classify("ALTER TABLE t ALTER COLUMN c TYPE bigint;");
257 assert_eq!(report.count_at(LintSeverity::Warning), 1);
258 assert_eq!(report.findings[0].category, LintCategory::TypeNarrowing);
259 }
260
261 #[test]
262 fn word_boundary_prevents_false_positive() {
263 let report = classify("CREATE TABLE dropdown (id int);");
265 assert!(report.is_clean());
266 }
267
268 #[test]
269 fn multiple_findings_across_statements() {
270 let report = classify("DROP TABLE a; CREATE INDEX i ON b (c);");
271 assert_eq!(report.statements, 2);
272 assert!(report.has_critical());
273 assert_eq!(report.count_at(LintSeverity::Warning), 1);
274 }
275
276 #[test]
277 fn hostile_input_does_not_panic() {
278 let _ = classify("'''''''\"\"\"\"DROP '");
280 let _ = classify("/* /* nested */ DROP TABLE");
281 let _ = classify(";");
282 }
283}