Skip to content

Commit 45a7c7e

Browse files
sj-robertrobert-sjoblom
authored andcommitted
feat(rules): add PGM021 (ADD UNIQUE without USING INDEX) and PGM022 (DROP TABLE)
- PGM021 (Critical): flags ADD UNIQUE on existing table without prior unique index - PGM022 (Minor): flags DROP TABLE on existing table for human review - Add matrix interaction tests for PGM012/PGM021 interplay - Update README with new rules, notes, and rule count (25 → 28)
1 parent 3fa5828 commit 45a7c7e

10 files changed

Lines changed: 493 additions & 4 deletions

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Static analyzer for PostgreSQL migration files.
77

88
## What it does
99

10-
pg-migration-lint replays your full migration history to build an internal table catalog, then lints only new or changed migration files against 25 safety and correctness rules. It catches dangerous operations -- missing `CONCURRENTLY`, table rewrites, missing indexes on foreign keys, unsafe constraint additions, silent constraint removal, risky renames, type anti-patterns -- before they reach production.
10+
pg-migration-lint replays your full migration history to build an internal table catalog, then lints only new or changed migration files against 28 safety and correctness rules. It catches dangerous operations -- missing `CONCURRENTLY`, table rewrites, missing indexes on foreign keys, unsafe constraint additions, silent constraint removal, risky renames, type anti-patterns -- before they reach production.
1111

1212
Output formats include SARIF (for GitHub Code Scanning inline PR annotations), SonarQube Generic Issue Import JSON, and human-readable text.
1313

@@ -36,7 +36,7 @@ chmod +x pg-migration-lint
3636

3737
## Rules
3838

39-
pg-migration-lint ships with 25 rules across two categories: migration safety rules (PGM001-PGM020) and PostgreSQL type anti-pattern rules (PGM101-PGM105, PGM108).
39+
pg-migration-lint ships with 28 rules across two categories: migration safety rules (PGM001-PGM022) and PostgreSQL type anti-pattern rules (PGM101-PGM105, PGM108).
4040

4141
### Migration Safety Rules
4242

@@ -62,6 +62,8 @@ pg-migration-lint ships with 25 rules across two categories: migration safety ru
6262
| PGM018 | Critical | `ADD CHECK` without `NOT VALID` | `ALTER TABLE orders ADD CONSTRAINT chk CHECK (amount > 0);` |
6363
| PGM019 | Info | `RENAME TABLE` on existing table | `ALTER TABLE orders RENAME TO orders_old;` |
6464
| PGM020 | Info | `RENAME COLUMN` on existing table | `ALTER TABLE orders RENAME COLUMN status TO order_status;` |
65+
| PGM021 | Critical | `ADD UNIQUE` without `USING INDEX` | `ALTER TABLE users ADD CONSTRAINT uq_email UNIQUE (email);` |
66+
| PGM022 | Minor | `DROP TABLE` on existing table | `DROP TABLE legacy_orders;` |
6567

6668
PGM001 and PGM002 do not fire when the table is created in the same set of changed files, because locking a new/empty table is harmless.
6769

@@ -71,6 +73,10 @@ PGM016, PGM017, and PGM018 only fire on tables that existed before the current s
7173

7274
PGM019 includes replacement detection: if the old table name is re-created in the same file (a common rename-and-replace pattern), the finding is suppressed.
7375

76+
PGM021 follows the same pattern as PGM012: create the unique index `CONCURRENTLY` first, then use `ADD CONSTRAINT ... UNIQUE USING INDEX` to promote it. This avoids a full table scan under an `ACCESS EXCLUSIVE` lock.
77+
78+
PGM022 only fires on tables that existed before the current set of changed files. Dropping a table created in the same changeset is harmless.
79+
7480
PGM008 is not a standalone rule -- it is a behavior modifier. All findings produced by other rules on `.down.sql` or rollback migrations are automatically capped to Info severity.
7581

7682
### PostgreSQL "Don't Do This" Rules

src/rules/mod.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub mod pgm017;
2727
pub mod pgm018;
2828
pub mod pgm019;
2929
pub mod pgm020;
30+
pub mod pgm021;
31+
pub mod pgm022;
3032
pub mod pgm101;
3133
pub mod pgm102;
3234
pub mod pgm103;
@@ -228,6 +230,8 @@ impl RuleRegistry {
228230
self.register(Box::new(pgm018::Pgm018));
229231
self.register(Box::new(pgm019::Pgm019));
230232
self.register(Box::new(pgm020::Pgm020));
233+
self.register(Box::new(pgm021::Pgm021));
234+
self.register(Box::new(pgm022::Pgm022));
231235
self.register(Box::new(pgm101::Pgm101));
232236
self.register(Box::new(pgm102::Pgm102));
233237
self.register(Box::new(pgm103::Pgm103));
@@ -267,8 +271,8 @@ mod tests {
267271
let mut registry = RuleRegistry::new();
268272
registry.register_defaults();
269273

270-
// We should have 25 rules (PGM001-PGM007, PGM009-PGM020, PGM101-PGM105, PGM108; PGM008 is not a rule)
271-
assert_eq!(registry.rules.len(), 25);
274+
// We should have 27 rules (PGM001-PGM007, PGM009-PGM022, PGM101-PGM105, PGM108; PGM008 is not a rule)
275+
assert_eq!(registry.rules.len(), 27);
272276
}
273277

274278
#[test]

src/rules/pgm021.rs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
//! PGM021 — `ADD UNIQUE` on existing table without `USING INDEX`
2+
//!
3+
//! Detects `ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (columns)` on tables that
4+
//! already exist where the target columns don't already have a covering unique
5+
//! index. The safe pattern is to first create a unique index CONCURRENTLY, then
6+
//! add the constraint using that index.
7+
8+
use crate::parser::ir::{AlterTableAction, IrNode, Located, TableConstraint};
9+
use crate::rules::{Finding, LintContext, Rule, Severity, alter_table_check};
10+
11+
/// Rule that flags adding a UNIQUE constraint to an existing table without a
12+
/// pre-existing unique index on the constraint columns.
13+
pub struct Pgm021;
14+
15+
impl Rule for Pgm021 {
16+
fn id(&self) -> &'static str {
17+
"PGM021"
18+
}
19+
20+
fn default_severity(&self) -> Severity {
21+
Severity::Critical
22+
}
23+
24+
fn description(&self) -> &'static str {
25+
"ADD UNIQUE on existing table without USING INDEX"
26+
}
27+
28+
fn explain(&self) -> &'static str {
29+
"PGM021 — ADD UNIQUE on existing table without USING INDEX\n\
30+
\n\
31+
What it detects:\n\
32+
ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (columns) where the table\n\
33+
already exists and the target columns don't have a pre-existing unique\n\
34+
index.\n\
35+
\n\
36+
Why it's dangerous:\n\
37+
Adding a UNIQUE constraint inline builds a unique index under an ACCESS\n\
38+
EXCLUSIVE lock, blocking all reads and writes for the duration. For\n\
39+
large tables this can cause extended downtime. Unlike CHECK and FOREIGN\n\
40+
KEY constraints, NOT VALID does NOT apply to UNIQUE constraints, so\n\
41+
there is no NOT VALID escape hatch.\n\
42+
\n\
43+
Example (bad):\n\
44+
ALTER TABLE orders ADD CONSTRAINT uq_email UNIQUE (email);\n\
45+
\n\
46+
Fix (safe pattern — build unique index concurrently first):\n\
47+
CREATE UNIQUE INDEX CONCURRENTLY idx_orders_email ON orders (email);\n\
48+
ALTER TABLE orders ADD CONSTRAINT uq_email UNIQUE USING INDEX idx_orders_email;"
49+
}
50+
51+
fn check(&self, statements: &[Located<IrNode>], ctx: &LintContext<'_>) -> Vec<Finding> {
52+
alter_table_check::check_alter_actions(statements, ctx, |at, action, stmt, ctx| {
53+
let AlterTableAction::AddConstraint(TableConstraint::Unique { columns, .. }) = action
54+
else {
55+
return vec![];
56+
};
57+
58+
let table_key = at.name.catalog_key();
59+
let Some(table) = ctx.catalog_before.get_table(table_key) else {
60+
return vec![];
61+
};
62+
63+
if table.has_unique_covering(columns) {
64+
return vec![];
65+
}
66+
67+
vec![self.make_finding(
68+
format!(
69+
"ADD UNIQUE on existing table '{table}' without a \
70+
pre-existing unique index on column(s) [{columns}]. \
71+
Create a unique index CONCURRENTLY first, then use \
72+
ADD CONSTRAINT ... UNIQUE USING INDEX.",
73+
table = at.name.display_name(),
74+
columns = columns.join(", "),
75+
),
76+
ctx.file,
77+
&stmt.span,
78+
)]
79+
})
80+
}
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use crate::catalog::Catalog;
87+
use crate::catalog::builder::CatalogBuilder;
88+
use crate::parser::ir::*;
89+
use crate::rules::test_helpers::{located, make_ctx};
90+
use std::collections::HashSet;
91+
use std::path::PathBuf;
92+
93+
fn add_unique_stmt(table: &str, columns: &[&str]) -> Located<IrNode> {
94+
located(IrNode::AlterTable(AlterTable {
95+
name: QualifiedName::unqualified(table),
96+
actions: vec![AlterTableAction::AddConstraint(TableConstraint::Unique {
97+
name: Some(format!("uq_{}", columns.join("_"))),
98+
columns: columns.iter().map(|s| s.to_string()).collect(),
99+
})],
100+
}))
101+
}
102+
103+
#[test]
104+
fn test_add_unique_no_existing_index_fires() {
105+
let before = CatalogBuilder::new()
106+
.table("orders", |t| {
107+
t.column("id", "bigint", false)
108+
.column("email", "text", false);
109+
})
110+
.build();
111+
let after = before.clone();
112+
let file = PathBuf::from("migrations/002.sql");
113+
let created = HashSet::new();
114+
let ctx = make_ctx(&before, &after, &file, &created);
115+
116+
let stmts = vec![add_unique_stmt("orders", &["email"])];
117+
118+
let findings = Pgm021.check(&stmts, &ctx);
119+
insta::assert_yaml_snapshot!(findings);
120+
}
121+
122+
#[test]
123+
fn test_add_unique_with_existing_unique_index_no_finding() {
124+
let before = CatalogBuilder::new()
125+
.table("orders", |t| {
126+
t.column("id", "bigint", false)
127+
.column("email", "text", false)
128+
.index("idx_orders_email", &["email"], true);
129+
})
130+
.build();
131+
let after = before.clone();
132+
let file = PathBuf::from("migrations/002.sql");
133+
let created = HashSet::new();
134+
let ctx = make_ctx(&before, &after, &file, &created);
135+
136+
let stmts = vec![add_unique_stmt("orders", &["email"])];
137+
138+
let findings = Pgm021.check(&stmts, &ctx);
139+
assert!(findings.is_empty());
140+
}
141+
142+
#[test]
143+
fn test_add_unique_on_new_table_no_finding() {
144+
let before = Catalog::new();
145+
let after = CatalogBuilder::new()
146+
.table("orders", |t| {
147+
t.column("id", "bigint", false)
148+
.column("email", "text", false);
149+
})
150+
.build();
151+
let file = PathBuf::from("migrations/001.sql");
152+
let mut created = HashSet::new();
153+
created.insert("orders".to_string());
154+
let ctx = make_ctx(&before, &after, &file, &created);
155+
156+
let stmts = vec![add_unique_stmt("orders", &["email"])];
157+
158+
let findings = Pgm021.check(&stmts, &ctx);
159+
assert!(findings.is_empty());
160+
}
161+
162+
#[test]
163+
fn test_table_not_in_catalog_no_finding() {
164+
let before = Catalog::new();
165+
let after = before.clone();
166+
let file = PathBuf::from("migrations/002.sql");
167+
let created = HashSet::new();
168+
let ctx = make_ctx(&before, &after, &file, &created);
169+
170+
let stmts = vec![add_unique_stmt("nonexistent", &["email"])];
171+
172+
let findings = Pgm021.check(&stmts, &ctx);
173+
assert!(findings.is_empty());
174+
}
175+
176+
#[test]
177+
fn test_non_unique_index_still_fires() {
178+
let before = CatalogBuilder::new()
179+
.table("orders", |t| {
180+
t.column("id", "bigint", false)
181+
.column("email", "text", false)
182+
.index("idx_orders_email", &["email"], false); // NOT unique
183+
})
184+
.build();
185+
let after = before.clone();
186+
let file = PathBuf::from("migrations/002.sql");
187+
let created = HashSet::new();
188+
let ctx = make_ctx(&before, &after, &file, &created);
189+
190+
let stmts = vec![add_unique_stmt("orders", &["email"])];
191+
192+
let findings = Pgm021.check(&stmts, &ctx);
193+
insta::assert_yaml_snapshot!(findings);
194+
}
195+
196+
#[test]
197+
fn test_add_unique_with_existing_unique_constraint_no_finding() {
198+
let before = CatalogBuilder::new()
199+
.table("orders", |t| {
200+
t.column("id", "bigint", false)
201+
.column("email", "text", false)
202+
.unique("uq_orders_email", &["email"]);
203+
})
204+
.build();
205+
let after = before.clone();
206+
let file = PathBuf::from("migrations/002.sql");
207+
let created = HashSet::new();
208+
let ctx = make_ctx(&before, &after, &file, &created);
209+
210+
let stmts = vec![add_unique_stmt("orders", &["email"])];
211+
212+
let findings = Pgm021.check(&stmts, &ctx);
213+
assert!(findings.is_empty());
214+
}
215+
}

0 commit comments

Comments
 (0)