Skip to main content

arcature_data/lint/
classify.rs

1//! The migration lint classifier: a deterministic, database-free scan of
2//! [`SqlStatement`] text for real PostgreSQL migration risks (PROGRAM.md
3//! AP2.1-6).
4//!
5//! Classification is **deterministic and side-effect-free** — it reads no
6//! environment variables, connects to no database, and panics on no input.
7//! Each statement is upper-cased and scanned for keyword sequences that name
8//! real PostgreSQL deployment hazards. The categories and severities are the
9//! ones named in PROGRAM.md AP2.1-6: destructive drops, unsafe `NOT NULL`,
10//! type narrowing, dangerous renames, and blocking indexes.
11//!
12//! # What this does NOT do
13//!
14//! It does not assume a reverse-migration exists, does not execute the SQL,
15//! does not compare schemas, and does not require a shadow database. Surfacing
16//! a destructive change is the deliverable; the operator decides whether to
17//! apply. The shadow-DB `diff`/`drift`/`verify` paths (PROGRAM.md AP2.1-6) are
18//! deferred to a later wave and would feed *rendered* SQL into this same
19//! classifier.
20
21use super::finding::{Finding, LintCategory, LintReport, LintSeverity};
22use super::statement::SqlStatement;
23
24/// Classify a set of [`SqlStatement`]s and return a [`LintReport`].
25///
26/// Deterministic; never panics. See the module docs for what is and is not
27/// detected.
28#[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/// Classify a single script string (split into statements first).
42#[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    // Destructive drops. DROP TABLE / DROP COLUMN are Critical (row data loss);
50    // other DROP forms are Warning (structural, reversible with care).
51    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    // ADD COLUMN ... NOT NULL without a DEFAULT — fails on existing rows and
80    // breaks rolling upgrades (old app instances still insert without it).
81    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    // SET NOT NULL on an existing column — table rewrite, fails on existing
99    // NULLs. (ADD ... NOT NULL is handled above; this catches the standalone
100    // ALTER COLUMN ... SET NOT NULL form.)
101    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    // ALTER COLUMN ... TYPE — may narrow the value domain or rewrite the table.
116    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    // RENAME — breaks code referencing the old name; rolling-upgrade hazard.
131    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    // CREATE INDEX without CONCURRENTLY — blocks writes during the build.
146    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
163/// Word-boundary-aware substring test on an upper-cased SQL string.
164///
165/// `needle` must be upper-case. Returns `true` when `needle` appears as a
166/// run of SQL tokens (surrounded by non-alphanumeric boundaries), so that
167/// e.g. searching for `DROP TABLE` does not match `DROPDOWN TABLE`.
168fn 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        // `DROPDOWN` must not match `DROP`.
264        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        // Garbage / unterminated / nested quotes — must not panic.
279        let _ = classify("'''''''\"\"\"\"DROP '");
280        let _ = classify("/* /* nested */ DROP TABLE");
281        let _ = classify(";");
282    }
283}