Skip to content

Commit 9397512

Browse files
authored
linter: add rule ban-uncommitted-transaction (#699)
We have an existing rule to handle transaction nesting, this rule makes sure you commit your transactions! rel: #697
1 parent 791d99b commit 9397512

File tree

6 files changed

+313
-0
lines changed

6 files changed

+313
-0
lines changed

crates/squawk_linter/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ use rules::ban_drop_database;
3939
use rules::ban_drop_not_null;
4040
use rules::ban_drop_table;
4141
use rules::ban_truncate_cascade;
42+
use rules::ban_uncommitted_transaction;
4243
use rules::changing_column_type;
4344
use rules::constraint_missing_not_valid;
4445
use rules::disallow_unique_constraint;
@@ -88,6 +89,7 @@ pub enum Rule {
8889
BanAlterDomainWithAddConstraint,
8990
BanTruncateCascade,
9091
RequireTimeoutSettings,
92+
BanUncommittedTransaction,
9193
// xtask:new-rule:error-name
9294
}
9395

@@ -129,6 +131,7 @@ impl TryFrom<&str> for Rule {
129131
"ban-alter-domain-with-add-constraint" => Ok(Rule::BanAlterDomainWithAddConstraint),
130132
"ban-truncate-cascade" => Ok(Rule::BanTruncateCascade),
131133
"require-timeout-settings" => Ok(Rule::RequireTimeoutSettings),
134+
"ban-uncommitted-transaction" => Ok(Rule::BanUncommittedTransaction),
132135
// xtask:new-rule:str-name
133136
_ => Err(format!("Unknown violation name: {s}")),
134137
}
@@ -190,6 +193,7 @@ impl fmt::Display for Rule {
190193
Rule::BanAlterDomainWithAddConstraint => "ban-alter-domain-with-add-constraint",
191194
Rule::BanTruncateCascade => "ban-truncate-cascade",
192195
Rule::RequireTimeoutSettings => "require-timeout-settings",
196+
Rule::BanUncommittedTransaction => "ban-uncommitted-transaction",
193197
// xtask:new-rule:variant-to-name
194198
};
195199
write!(f, "{val}")
@@ -407,6 +411,9 @@ impl Linter {
407411
if self.rules.contains(&Rule::RequireTimeoutSettings) {
408412
require_timeout_settings(self, file);
409413
}
414+
if self.rules.contains(&Rule::BanUncommittedTransaction) {
415+
ban_uncommitted_transaction(self, file);
416+
}
410417
// xtask:new-rule:rule-call
411418

412419
// locate any ignores in the file
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
use squawk_syntax::{
2+
Parse, SourceFile,
3+
ast::{self, AstNode},
4+
};
5+
6+
use crate::{Edit, Fix, Linter, Rule, Violation};
7+
8+
pub(crate) fn ban_uncommitted_transaction(ctx: &mut Linter, parse: &Parse<SourceFile>) {
9+
let file = parse.tree();
10+
let mut uncommitted_begin: Option<ast::Begin> = None;
11+
12+
for stmt in file.stmts() {
13+
match stmt {
14+
ast::Stmt::Begin(begin) => {
15+
uncommitted_begin = Some(begin);
16+
}
17+
ast::Stmt::Commit(_) | ast::Stmt::Rollback(_) => {
18+
uncommitted_begin = None;
19+
}
20+
_ => (),
21+
}
22+
}
23+
24+
if let Some(begin) = uncommitted_begin {
25+
let end_pos = file.syntax().text_range().end();
26+
let fix = Fix::new("Add COMMIT", vec![Edit::insert("\nCOMMIT;\n", end_pos)]);
27+
28+
ctx.report(
29+
Violation::for_node(
30+
Rule::BanUncommittedTransaction,
31+
"Transaction never committed or rolled back.".to_string(),
32+
begin.syntax(),
33+
)
34+
.help("Add a `COMMIT` or `ROLLBACK` statement to complete the transaction.")
35+
.fix(Some(fix)),
36+
);
37+
}
38+
}
39+
40+
#[cfg(test)]
41+
mod test {
42+
use insta::{assert_debug_snapshot, assert_snapshot};
43+
44+
use crate::{Linter, Rule, test_utils::fix_sql};
45+
use squawk_syntax::SourceFile;
46+
47+
#[test]
48+
fn uncommitted_transaction_err() {
49+
let sql = r#"
50+
BEGIN;
51+
CREATE TABLE users (id bigint);
52+
"#;
53+
let file = SourceFile::parse(sql);
54+
let mut linter = Linter::from([Rule::BanUncommittedTransaction]);
55+
let errors = linter.lint(&file, sql);
56+
assert_ne!(errors.len(), 0);
57+
assert_debug_snapshot!(errors, @r#"
58+
[
59+
Violation {
60+
code: BanUncommittedTransaction,
61+
message: "Transaction never committed or rolled back.",
62+
text_range: 1..6,
63+
help: Some(
64+
"Add a `COMMIT` or `ROLLBACK` statement to complete the transaction.",
65+
),
66+
fix: Some(
67+
Fix {
68+
title: "Add COMMIT",
69+
edits: [
70+
Edit {
71+
text_range: 48..48,
72+
text: Some(
73+
"\nCOMMIT;\n",
74+
),
75+
},
76+
],
77+
},
78+
),
79+
},
80+
]
81+
"#);
82+
}
83+
84+
#[test]
85+
fn committed_transaction_ok() {
86+
let sql = r#"
87+
BEGIN;
88+
CREATE TABLE users (id bigint);
89+
COMMIT;
90+
"#;
91+
let file = SourceFile::parse(sql);
92+
let mut linter = Linter::from([Rule::BanUncommittedTransaction]);
93+
let errors = linter.lint(&file, sql);
94+
assert_eq!(errors.len(), 0);
95+
}
96+
97+
#[test]
98+
fn rolled_back_transaction_ok() {
99+
let sql = r#"
100+
BEGIN;
101+
CREATE TABLE users (id bigint);
102+
ROLLBACK;
103+
"#;
104+
let file = SourceFile::parse(sql);
105+
let mut linter = Linter::from([Rule::BanUncommittedTransaction]);
106+
let errors = linter.lint(&file, sql);
107+
assert_eq!(errors.len(), 0);
108+
}
109+
110+
#[test]
111+
fn no_transaction_ok() {
112+
let sql = r#"
113+
CREATE TABLE users (id bigint);
114+
"#;
115+
let file = SourceFile::parse(sql);
116+
let mut linter = Linter::from([Rule::BanUncommittedTransaction]);
117+
let errors = linter.lint(&file, sql);
118+
assert_eq!(errors.len(), 0);
119+
}
120+
121+
#[test]
122+
fn multiple_transactions_last_uncommitted_err() {
123+
let sql = r#"
124+
BEGIN;
125+
CREATE TABLE users (id bigint);
126+
COMMIT;
127+
128+
BEGIN;
129+
CREATE TABLE posts (id bigint);
130+
"#;
131+
let file = SourceFile::parse(sql);
132+
let mut linter = Linter::from([Rule::BanUncommittedTransaction]);
133+
let errors = linter.lint(&file, sql);
134+
assert_ne!(errors.len(), 0);
135+
assert_debug_snapshot!(errors, @r#"
136+
[
137+
Violation {
138+
code: BanUncommittedTransaction,
139+
message: "Transaction never committed or rolled back.",
140+
text_range: 49..54,
141+
help: Some(
142+
"Add a `COMMIT` or `ROLLBACK` statement to complete the transaction.",
143+
),
144+
fix: Some(
145+
Fix {
146+
title: "Add COMMIT",
147+
edits: [
148+
Edit {
149+
text_range: 96..96,
150+
text: Some(
151+
"\nCOMMIT;\n",
152+
),
153+
},
154+
],
155+
},
156+
),
157+
},
158+
]
159+
"#);
160+
}
161+
162+
#[test]
163+
fn start_transaction_uncommitted_err() {
164+
let sql = r#"
165+
START TRANSACTION;
166+
CREATE TABLE users (id bigint);
167+
"#;
168+
let file = SourceFile::parse(sql);
169+
let mut linter = Linter::from([Rule::BanUncommittedTransaction]);
170+
let errors = linter.lint(&file, sql);
171+
assert_ne!(errors.len(), 0);
172+
assert_debug_snapshot!(errors, @r#"
173+
[
174+
Violation {
175+
code: BanUncommittedTransaction,
176+
message: "Transaction never committed or rolled back.",
177+
text_range: 1..18,
178+
help: Some(
179+
"Add a `COMMIT` or `ROLLBACK` statement to complete the transaction.",
180+
),
181+
fix: Some(
182+
Fix {
183+
title: "Add COMMIT",
184+
edits: [
185+
Edit {
186+
text_range: 60..60,
187+
text: Some(
188+
"\nCOMMIT;\n",
189+
),
190+
},
191+
],
192+
},
193+
),
194+
},
195+
]
196+
"#);
197+
}
198+
199+
#[test]
200+
fn nested_begin_only_last_uncommitted_err() {
201+
let sql = r#"
202+
BEGIN;
203+
BEGIN;
204+
COMMIT;
205+
"#;
206+
let file = SourceFile::parse(sql);
207+
let mut linter = Linter::from([Rule::BanUncommittedTransaction]);
208+
let errors = linter.lint(&file, sql);
209+
assert_eq!(errors.len(), 0);
210+
}
211+
212+
#[test]
213+
fn begin_work_uncommitted_err() {
214+
let sql = r#"
215+
BEGIN WORK;
216+
CREATE TABLE users (id bigint);
217+
"#;
218+
let file = SourceFile::parse(sql);
219+
let mut linter = Linter::from([Rule::BanUncommittedTransaction]);
220+
let errors = linter.lint(&file, sql);
221+
assert_ne!(errors.len(), 0);
222+
assert_debug_snapshot!(errors, @r#"
223+
[
224+
Violation {
225+
code: BanUncommittedTransaction,
226+
message: "Transaction never committed or rolled back.",
227+
text_range: 1..11,
228+
help: Some(
229+
"Add a `COMMIT` or `ROLLBACK` statement to complete the transaction.",
230+
),
231+
fix: Some(
232+
Fix {
233+
title: "Add COMMIT",
234+
edits: [
235+
Edit {
236+
text_range: 53..53,
237+
text: Some(
238+
"\nCOMMIT;\n",
239+
),
240+
},
241+
],
242+
},
243+
),
244+
},
245+
]
246+
"#);
247+
}
248+
249+
#[test]
250+
fn fix_adds_commit() {
251+
let sql = r#"
252+
BEGIN;
253+
CREATE TABLE users (id bigint);
254+
"#;
255+
let fixed = fix_sql(sql, Rule::BanUncommittedTransaction);
256+
assert_snapshot!(fixed, @r"
257+
BEGIN;
258+
CREATE TABLE users (id bigint);
259+
260+
COMMIT;
261+
");
262+
}
263+
264+
#[test]
265+
fn fix_adds_commit_to_start_transaction() {
266+
let sql = r#"START TRANSACTION;
267+
CREATE TABLE posts (id bigint);"#;
268+
let fixed = fix_sql(sql, Rule::BanUncommittedTransaction);
269+
assert_snapshot!(fixed, @r"START TRANSACTION;
270+
CREATE TABLE posts (id bigint);
271+
COMMIT;
272+
");
273+
}
274+
}

crates/squawk_linter/src/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub(crate) mod ban_drop_database;
1212
pub(crate) mod ban_drop_not_null;
1313
pub(crate) mod ban_drop_table;
1414
pub(crate) mod ban_truncate_cascade;
15+
pub(crate) mod ban_uncommitted_transaction;
1516
pub(crate) mod changing_column_type;
1617
pub(crate) mod constraint_missing_not_valid;
1718
pub(crate) mod disallow_unique_constraint;
@@ -43,6 +44,7 @@ pub(crate) use ban_drop_database::ban_drop_database;
4344
pub(crate) use ban_drop_not_null::ban_drop_not_null;
4445
pub(crate) use ban_drop_table::ban_drop_table;
4546
pub(crate) use ban_truncate_cascade::ban_truncate_cascade;
47+
pub(crate) use ban_uncommitted_transaction::ban_uncommitted_transaction;
4648
pub(crate) use changing_column_type::changing_column_type;
4749
pub(crate) use constraint_missing_not_valid::constraint_missing_not_valid;
4850
pub(crate) use disallow_unique_constraint::disallow_unique_constraint;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
id: ban-uncommitted-transaction
3+
title: ban-uncommitted-transaction
4+
---
5+
6+
## problem
7+
8+
If you start a transaction and forget to add a `commit` statement, then the migration will run successfully, but it [won't have actually applied the changes](https://github.com/sbdchd/squawk/issues/697#issue-3570671498).
9+
10+
```sql
11+
begin;
12+
create table users (id bigint);
13+
-- missing `commit`!
14+
```
15+
16+
## solution
17+
18+
Add a `commit` statement to complete your transaction!
19+
20+
```sql
21+
begin;
22+
create table users (id bigint);
23+
commit;
24+
```

docs/sidebars.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ module.exports = {
3333
"ban-truncate-cascade",
3434
"syntax-error",
3535
"require-timeout-settings",
36+
"ban-uncommitted-transaction",
3637
// xtask:new-rule:error-name
3738
],
3839
},

docs/src/pages/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ const rules = [
198198
tags: ["locking"],
199199
description: "Require lock and statement timeouts",
200200
},
201+
{
202+
name: "ban-uncommitted-transaction",
203+
tags: ["schema"],
204+
description: "Ensure all transactions are committed",
205+
},
201206
// xtask:new-rule:rule-doc-meta
202207
]
203208

0 commit comments

Comments
 (0)