Skip to content

Commit 2157403

Browse files
committed
linter: new rule: prefer repack
1 parent 10ec314 commit 2157403

12 files changed

Lines changed: 438 additions & 6 deletions

File tree

crates/squawk_linter/src/lib.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ use rules::identifier_too_long;
4747
use rules::prefer_bigint_over_int;
4848
use rules::prefer_bigint_over_smallint;
4949
use rules::prefer_identity;
50+
use rules::prefer_repack;
5051
use rules::prefer_robust_stmts;
5152
use rules::prefer_text_field;
5253
use rules::prefer_timestamptz;
@@ -79,6 +80,7 @@ pub enum Rule {
7980
PreferBigintOverInt,
8081
PreferBigintOverSmallint,
8182
PreferIdentity,
83+
PreferRepack,
8284
PreferRobustStmts,
8385
PreferTextField,
8486
PreferTimestampTz,
@@ -131,6 +133,7 @@ impl TryFrom<&str> for Rule {
131133
"prefer-bigint-over-int" => Ok(Rule::PreferBigintOverInt),
132134
"prefer-bigint-over-smallint" => Ok(Rule::PreferBigintOverSmallint),
133135
"prefer-identity" => Ok(Rule::PreferIdentity),
136+
"prefer-repack" => Ok(Rule::PreferRepack),
134137
"prefer-robust-stmts" => Ok(Rule::PreferRobustStmts),
135138
"prefer-text-field" => Ok(Rule::PreferTextField),
136139
// this is typo'd so we just support both
@@ -199,6 +202,7 @@ impl fmt::Display for Rule {
199202
Rule::PreferBigintOverInt => "prefer-bigint-over-int",
200203
Rule::PreferBigintOverSmallint => "prefer-bigint-over-smallint",
201204
Rule::PreferIdentity => "prefer-identity",
205+
Rule::PreferRepack => "prefer-repack",
202206
Rule::PreferRobustStmts => "prefer-robust-stmts",
203207
Rule::PreferTextField => "prefer-text-field",
204208
Rule::PreferTimestampTz => "prefer-timestamp-tz",
@@ -332,7 +336,7 @@ impl Violation {
332336
}
333337
}
334338

335-
#[derive(Default)]
339+
#[derive(Clone, Default)]
336340
pub struct LinterSettings {
337341
pub pg_version: Version,
338342
pub assume_in_transaction: bool,
@@ -410,6 +414,9 @@ impl Linter {
410414
if self.rules.contains(&Rule::PreferIdentity) {
411415
prefer_identity(self, file);
412416
}
417+
if self.rules.contains(&Rule::PreferRepack) {
418+
prefer_repack(self, file);
419+
}
413420
if self.rules.contains(&Rule::PreferRobustStmts) {
414421
prefer_robust_stmts(self, file);
415422
}

crates/squawk_linter/src/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub(crate) mod identifier_too_long;
2020
pub(crate) mod prefer_bigint_over_int;
2121
pub(crate) mod prefer_bigint_over_smallint;
2222
pub(crate) mod prefer_identity;
23+
pub(crate) mod prefer_repack;
2324
pub(crate) mod prefer_robust_stmts;
2425
pub(crate) mod prefer_text_field;
2526
pub(crate) mod prefer_timestamptz;
@@ -57,6 +58,7 @@ pub(crate) use identifier_too_long::identifier_too_long;
5758
pub(crate) use prefer_bigint_over_int::prefer_bigint_over_int;
5859
pub(crate) use prefer_bigint_over_smallint::prefer_bigint_over_smallint;
5960
pub(crate) use prefer_identity::prefer_identity;
61+
pub(crate) use prefer_repack::prefer_repack;
6062
pub(crate) use prefer_robust_stmts::prefer_robust_stmts;
6163
pub(crate) use prefer_text_field::prefer_text_field;
6264
pub(crate) use prefer_timestamptz::prefer_timestamptz;
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
use squawk_syntax::{
2+
Parse, SourceFile,
3+
ast::{self, AstNode},
4+
};
5+
6+
use crate::{Edit, Fix, Linter, Rule, Version, Violation};
7+
8+
fn vacuum_full_fix(vacuum: &ast::Vacuum) -> Option<Fix> {
9+
let tables = vacuum.table_and_columns_list()?;
10+
let tables = tables.syntax();
11+
let replacement = format!("repack (concurrently) {tables}");
12+
let edit = Edit::replace(vacuum.syntax().text_range(), replacement);
13+
Some(Fix::new("Replace with `repack (concurrently)`", vec![edit]))
14+
}
15+
16+
fn cluster_fix(cluster: &ast::Cluster) -> Option<Fix> {
17+
let replacement = if let Some(on_path) = cluster.on_path() {
18+
let index = cluster.path()?;
19+
let table = on_path.path()?;
20+
format!(
21+
"repack (concurrently) {} using index {}",
22+
table.syntax(),
23+
index.syntax()
24+
)
25+
} else if let Some(table) = cluster.path() {
26+
if let Some(index_name) = cluster.using_method().and_then(|method| method.name_ref()) {
27+
format!(
28+
"repack (concurrently) {} using index {}",
29+
table.syntax(),
30+
index_name.syntax()
31+
)
32+
} else {
33+
format!("repack (concurrently) {}", table.syntax())
34+
}
35+
} else {
36+
"repack (concurrently)".to_string()
37+
};
38+
let edit = Edit::replace(cluster.syntax().text_range(), replacement);
39+
Some(Fix::new("Replace with `repack (concurrently)`", vec![edit]))
40+
}
41+
42+
pub(crate) fn prefer_repack(ctx: &mut Linter, parse: &Parse<SourceFile>) {
43+
if ctx.settings.pg_version < Version::new(19, None, None) {
44+
return;
45+
}
46+
for stmt in parse.tree().stmts() {
47+
match stmt {
48+
ast::Stmt::Cluster(cluster) => {
49+
let fix = cluster_fix(&cluster);
50+
ctx.report(
51+
Violation::for_node(
52+
Rule::PreferRepack,
53+
"`cluster` requires an `ACCESS EXCLUSIVE` lock which blocks reads and writes to the table.".into(),
54+
cluster.syntax(),
55+
)
56+
.help("Use `repack` to rewrite the table without blocking reads and writes.")
57+
.fix(fix),
58+
);
59+
}
60+
ast::Stmt::Vacuum(vacuum) => {
61+
if vacuum.is_full() {
62+
let fix = vacuum_full_fix(&vacuum);
63+
ctx.report(
64+
Violation::for_node(
65+
Rule::PreferRepack,
66+
"`vacuum full` requires an `ACCESS EXCLUSIVE` lock which blocks reads and writes to the table.".into(),
67+
vacuum.syntax(),
68+
)
69+
.help("Use `repack` to rewrite the table without blocking reads and writes.")
70+
.fix(fix),
71+
);
72+
}
73+
}
74+
_ => (),
75+
}
76+
}
77+
}
78+
79+
#[cfg(test)]
80+
mod test {
81+
use insta::assert_snapshot;
82+
83+
use crate::{
84+
LinterSettings, Rule,
85+
test_utils::{fix_sql_with, lint_errors_with, lint_ok, lint_ok_with},
86+
};
87+
88+
fn pg19() -> LinterSettings {
89+
LinterSettings {
90+
pg_version: "19".parse().expect("Invalid PostgreSQL version"),
91+
..Default::default()
92+
}
93+
}
94+
95+
#[test]
96+
fn cluster_err() {
97+
let sql = "CLUSTER foo;";
98+
assert_snapshot!(lint_errors_with(sql, pg19(), Rule::PreferRepack), @"
99+
warning[prefer-repack]: `cluster` requires an `ACCESS EXCLUSIVE` lock which blocks reads and writes to the table.
100+
╭▸
101+
1 │ CLUSTER foo;
102+
│ ━━━━━━━━━━━
103+
104+
├ help: Use `repack` to rewrite the table without blocking reads and writes.
105+
╭╴
106+
1 - CLUSTER foo;
107+
1 + repack (concurrently) foo;
108+
╰╴
109+
");
110+
}
111+
112+
#[test]
113+
fn cluster_no_path_err() {
114+
let sql = "CLUSTER;";
115+
assert_snapshot!(lint_errors_with(sql, pg19(), Rule::PreferRepack), @"
116+
warning[prefer-repack]: `cluster` requires an `ACCESS EXCLUSIVE` lock which blocks reads and writes to the table.
117+
╭▸
118+
1 │ CLUSTER;
119+
│ ━━━━━━━
120+
121+
├ help: Use `repack` to rewrite the table without blocking reads and writes.
122+
╭╴
123+
1 - CLUSTER;
124+
1 + repack (concurrently);
125+
╰╴
126+
");
127+
}
128+
129+
#[test]
130+
fn vacuum_full_err() {
131+
let sql = "VACUUM FULL foo;";
132+
assert_snapshot!(lint_errors_with(sql, pg19(), Rule::PreferRepack), @"
133+
warning[prefer-repack]: `vacuum full` requires an `ACCESS EXCLUSIVE` lock which blocks reads and writes to the table.
134+
╭▸
135+
1 │ VACUUM FULL foo;
136+
│ ━━━━━━━━━━━━━━━
137+
138+
├ help: Use `repack` to rewrite the table without blocking reads and writes.
139+
╭╴
140+
1 - VACUUM FULL foo;
141+
1 + repack (concurrently) foo;
142+
╰╴
143+
");
144+
}
145+
146+
#[test]
147+
fn vacuum_full_option_list_err() {
148+
let sql = "VACUUM (FULL) foo;";
149+
assert_snapshot!(lint_errors_with(sql, pg19(), Rule::PreferRepack), @"
150+
warning[prefer-repack]: `vacuum full` requires an `ACCESS EXCLUSIVE` lock which blocks reads and writes to the table.
151+
╭▸
152+
1 │ VACUUM (FULL) foo;
153+
│ ━━━━━━━━━━━━━━━━━
154+
155+
├ help: Use `repack` to rewrite the table without blocking reads and writes.
156+
╭╴
157+
1 - VACUUM (FULL) foo;
158+
1 + repack (concurrently) foo;
159+
╰╴
160+
");
161+
}
162+
163+
#[test]
164+
fn vacuum_full_false_option_list_ok() {
165+
let sql = "VACUUM (FULL FALSE) foo;";
166+
lint_ok_with(sql, pg19(), Rule::PreferRepack);
167+
}
168+
169+
#[test]
170+
fn cluster_below_pg19_ok() {
171+
let sql = "CLUSTER foo;";
172+
lint_ok(sql, Rule::PreferRepack);
173+
}
174+
175+
#[test]
176+
fn vacuum_full_below_pg19_ok() {
177+
let sql = "VACUUM FULL foo;";
178+
lint_ok(sql, Rule::PreferRepack);
179+
}
180+
181+
#[test]
182+
fn vacuum_no_full_ok() {
183+
let sql = "VACUUM foo;";
184+
lint_ok_with(sql, pg19(), Rule::PreferRepack);
185+
}
186+
187+
#[test]
188+
fn vacuum_analyze_ok() {
189+
let sql = "VACUUM ANALYZE foo;";
190+
lint_ok_with(sql, pg19(), Rule::PreferRepack);
191+
}
192+
193+
#[test]
194+
fn vacuum_freeze_ok() {
195+
let sql = "VACUUM FREEZE foo;";
196+
lint_ok_with(sql, pg19(), Rule::PreferRepack);
197+
}
198+
199+
fn fix(sql: &str) -> String {
200+
fix_sql_with(sql, pg19(), Rule::PreferRepack)
201+
}
202+
203+
#[test]
204+
fn fix_vacuum_full() {
205+
assert_snapshot!(fix("VACUUM FULL foo;"), @"repack (concurrently) foo;");
206+
}
207+
208+
#[test]
209+
fn fix_vacuum_full_option_list() {
210+
assert_snapshot!(fix("VACUUM (FULL) foo;"), @"repack (concurrently) foo;");
211+
}
212+
213+
#[test]
214+
fn fix_vacuum_full_multiple_tables() {
215+
assert_snapshot!(fix("VACUUM FULL foo, bar;"), @"repack (concurrently) foo, bar;");
216+
}
217+
218+
#[test]
219+
fn fix_cluster_no_index() {
220+
assert_snapshot!(fix("CLUSTER foo;"), @"repack (concurrently) foo;");
221+
}
222+
223+
#[test]
224+
fn fix_cluster_with_index() {
225+
assert_snapshot!(fix("CLUSTER foo USING foo_pkey;"), @"repack (concurrently) foo using index foo_pkey;");
226+
}
227+
228+
#[test]
229+
fn fix_cluster_legacy_on_syntax() {
230+
assert_snapshot!(fix("CLUSTER verbose foo_pkey ON foo;"), @"repack (concurrently) foo using index foo_pkey;");
231+
}
232+
233+
#[test]
234+
fn fix_cluster_no_path() {
235+
assert_snapshot!(fix("CLUSTER;"), @"repack (concurrently);");
236+
}
237+
}

crates/squawk_linter/src/test_utils.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ pub(crate) fn lint_errors_with(sql: &str, settings: LinterSettings, rule: Rule)
5454
format_violations(sql, &errors)
5555
}
5656

57-
pub(crate) fn fix_sql(sql: &str, rule: Rule) -> String {
58-
let errors = lint(sql, rule);
57+
pub(crate) fn fix_sql_with(sql: &str, settings: LinterSettings, rule: Rule) -> String {
58+
let errors = lint_settings(sql, settings.clone(), rule);
5959
assert!(!errors.is_empty(), "Should start with linter errors");
6060

6161
let fixes = errors.into_iter().flat_map(|x| x.fix).collect::<Vec<_>>();
@@ -80,6 +80,7 @@ pub(crate) fn fix_sql(sql: &str, rule: Rule) -> String {
8080
"Shouldn't introduce any syntax errors"
8181
);
8282
let mut linter = Linter::from([rule]);
83+
linter.settings = settings;
8384
let errors = linter.lint(&file, &result);
8485
assert_eq!(
8586
errors.len(),
@@ -90,6 +91,10 @@ pub(crate) fn fix_sql(sql: &str, rule: Rule) -> String {
9091
result
9192
}
9293

94+
pub(crate) fn fix_sql(sql: &str, rule: Rule) -> String {
95+
fix_sql_with(sql, LinterSettings::default(), rule)
96+
}
97+
9398
fn format_violations(sql: &str, violations: &[Violation]) -> String {
9499
let mut buf = String::new();
95100
let renderer = Renderer::plain().decor_style(DecorStyle::Unicode);

crates/squawk_parser/src/generated/syntax_kind.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/squawk_parser/src/grammar.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8464,13 +8464,23 @@ fn cluster(p: &mut Parser<'_>) -> CompletedMarker {
84648464
opt_option_list(p);
84658465
}
84668466
let has_name = opt_path_name_ref(p).is_some();
8467-
if has_name && p.eat(ON_KW) {
8468-
path_name_ref(p);
8467+
if has_name {
8468+
opt_on_path(p);
84698469
}
84708470
opt_using_method(p);
84718471
m.complete(p, CLUSTER)
84728472
}
84738473

8474+
fn opt_on_path(p: &mut Parser<'_>) {
8475+
let m = p.start();
8476+
if p.eat(ON_KW) {
8477+
path_name_ref(p);
8478+
m.complete(p, ON_PATH);
8479+
} else {
8480+
m.abandon(p);
8481+
}
8482+
}
8483+
84748484
fn repack(p: &mut Parser<'_>) -> CompletedMarker {
84758485
assert!(p.at(REPACK_KW));
84768486
let m = p.start();

0 commit comments

Comments
 (0)