diff --git a/Cargo.lock b/Cargo.lock index 75a71dd2..4f86003e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,6 +2533,7 @@ dependencies = [ "serde", "serde_plain", "squawk-syntax", + "tabled", ] [[package]] diff --git a/crates/squawk/src/reporter.rs b/crates/squawk/src/reporter.rs index bed2f778..bf3af4e6 100644 --- a/crates/squawk/src/reporter.rs +++ b/crates/squawk/src/reporter.rs @@ -543,8 +543,8 @@ SELECT 1; ); assert!(res.is_ok()); - assert_snapshot!(String::from_utf8_lossy(&buff), @r" - main.sql:1:3: warning: require-timeout-settings Missing `set lock_timeout` before potentially slow operations + assert_snapshot!(String::from_utf8_lossy(&buff), @" + main.sql:1:3: warning: require-timeout-settings Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations main.sql:1:3: warning: require-timeout-settings Missing `set statement_timeout` before potentially slow operations main.sql:1:29: warning: adding-required-field Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. main.sql:1:29: warning: prefer-robust-stmts Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through. @@ -636,7 +636,7 @@ SELECT 1; ); assert!(res.is_ok()); - assert_snapshot!(String::from_utf8_lossy(&buff), @r#"[{"file":"main.sql","line":1,"column":3,"level":"Warning","message":"Missing `set lock_timeout` before potentially slow operations","help":"Configure a `lock_timeout` before this statement.","rule_name":"require-timeout-settings","column_end":63,"line_end":1},{"file":"main.sql","line":1,"column":3,"level":"Warning","message":"Missing `set statement_timeout` before potentially slow operations","help":"Configure a `statement_timeout` before this statement","rule_name":"require-timeout-settings","column_end":63,"line_end":1},{"file":"main.sql","line":1,"column":29,"level":"Warning","message":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required.","help":"Make the field nullable or add a non-VOLATILE DEFAULT","rule_name":"adding-required-field","column_end":62,"line_end":1},{"file":"main.sql","line":1,"column":29,"level":"Warning","message":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","help":null,"rule_name":"prefer-robust-stmts","column_end":62,"line_end":1},{"file":"main.sql","line":1,"column":46,"level":"Warning","message":"Using 32-bit integer fields can result in hitting the max `int` limit.","help":"Use 64-bit integer values instead to prevent hitting this limit.","rule_name":"prefer-bigint-over-int","column_end":53,"line_end":1},{"file":"main.sql","line":2,"column":23,"level":"Warning","message":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required.","help":"Make the field nullable or add a non-VOLATILE DEFAULT","rule_name":"adding-required-field","column_end":56,"line_end":2},{"file":"main.sql","line":2,"column":23,"level":"Warning","message":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","help":null,"rule_name":"prefer-robust-stmts","column_end":56,"line_end":2},{"file":"main.sql","line":2,"column":40,"level":"Warning","message":"Using 32-bit integer fields can result in hitting the max `int` limit.","help":"Use 64-bit integer values instead to prevent hitting this limit.","rule_name":"prefer-bigint-over-int","column_end":47,"line_end":2}]"#); + assert_snapshot!(String::from_utf8_lossy(&buff), @r#"[{"file":"main.sql","line":1,"column":3,"level":"Warning","message":"Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations","help":"Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes.","rule_name":"require-timeout-settings","column_end":63,"line_end":1},{"file":"main.sql","line":1,"column":3,"level":"Warning","message":"Missing `set statement_timeout` before potentially slow operations","help":"Configure a `statement_timeout` before this statement","rule_name":"require-timeout-settings","column_end":63,"line_end":1},{"file":"main.sql","line":1,"column":29,"level":"Warning","message":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required.","help":"Make the field nullable or add a non-VOLATILE DEFAULT","rule_name":"adding-required-field","column_end":62,"line_end":1},{"file":"main.sql","line":1,"column":29,"level":"Warning","message":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","help":null,"rule_name":"prefer-robust-stmts","column_end":62,"line_end":1},{"file":"main.sql","line":1,"column":46,"level":"Warning","message":"Using 32-bit integer fields can result in hitting the max `int` limit.","help":"Use 64-bit integer values instead to prevent hitting this limit.","rule_name":"prefer-bigint-over-int","column_end":53,"line_end":1},{"file":"main.sql","line":2,"column":23,"level":"Warning","message":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required.","help":"Make the field nullable or add a non-VOLATILE DEFAULT","rule_name":"adding-required-field","column_end":56,"line_end":2},{"file":"main.sql","line":2,"column":23,"level":"Warning","message":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","help":null,"rule_name":"prefer-robust-stmts","column_end":56,"line_end":2},{"file":"main.sql","line":2,"column":40,"level":"Warning","message":"Using 32-bit integer fields can result in hitting the max `int` limit.","help":"Use 64-bit integer values instead to prevent hitting this limit.","rule_name":"prefer-bigint-over-int","column_end":47,"line_end":2}]"#); } #[test] @@ -658,7 +658,7 @@ SELECT 1; assert!(res.is_ok()); - assert_snapshot!(String::from_utf8_lossy(&buff), @r#"[{"description":"Missing `set lock_timeout` before potentially slow operations Suggestion: Configure a `lock_timeout` before this statement.","severity":"minor","fingerprint":"106496a54f0e1a20","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"require-timeout-settings"},{"description":"Missing `set statement_timeout` before potentially slow operations Suggestion: Configure a `statement_timeout` before this statement","severity":"minor","fingerprint":"2904632bf27251d2","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"require-timeout-settings"},{"description":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. Suggestion: Make the field nullable or add a non-VOLATILE DEFAULT","severity":"minor","fingerprint":"87fbb54d93cdb8c9","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"adding-required-field"},{"description":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","severity":"minor","fingerprint":"21df0ee3817ad84","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"prefer-robust-stmts"},{"description":"Using 32-bit integer fields can result in hitting the max `int` limit. Suggestion: Use 64-bit integer values instead to prevent hitting this limit.","severity":"minor","fingerprint":"3d0e81dc13bc8757","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"prefer-bigint-over-int"},{"description":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. Suggestion: Make the field nullable or add a non-VOLATILE DEFAULT","severity":"minor","fingerprint":"4bdd655ad8e102ad","location":{"path":"main.sql","lines":{"begin":2,"end":2}},"check_name":"adding-required-field"},{"description":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","severity":"minor","fingerprint":"1b2e8c81e717c442","location":{"path":"main.sql","lines":{"begin":2,"end":2}},"check_name":"prefer-robust-stmts"},{"description":"Using 32-bit integer fields can result in hitting the max `int` limit. Suggestion: Use 64-bit integer values instead to prevent hitting this limit.","severity":"minor","fingerprint":"2bed2a431803b811","location":{"path":"main.sql","lines":{"begin":2,"end":2}},"check_name":"prefer-bigint-over-int"}]"#); + assert_snapshot!(String::from_utf8_lossy(&buff), @r#"[{"description":"Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations Suggestion: Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes.","severity":"minor","fingerprint":"c08bce2325e355cd","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"require-timeout-settings"},{"description":"Missing `set statement_timeout` before potentially slow operations Suggestion: Configure a `statement_timeout` before this statement","severity":"minor","fingerprint":"2904632bf27251d2","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"require-timeout-settings"},{"description":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. Suggestion: Make the field nullable or add a non-VOLATILE DEFAULT","severity":"minor","fingerprint":"87fbb54d93cdb8c9","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"adding-required-field"},{"description":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","severity":"minor","fingerprint":"21df0ee3817ad84","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"prefer-robust-stmts"},{"description":"Using 32-bit integer fields can result in hitting the max `int` limit. Suggestion: Use 64-bit integer values instead to prevent hitting this limit.","severity":"minor","fingerprint":"3d0e81dc13bc8757","location":{"path":"main.sql","lines":{"begin":1,"end":1}},"check_name":"prefer-bigint-over-int"},{"description":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. Suggestion: Make the field nullable or add a non-VOLATILE DEFAULT","severity":"minor","fingerprint":"4bdd655ad8e102ad","location":{"path":"main.sql","lines":{"begin":2,"end":2}},"check_name":"adding-required-field"},{"description":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","severity":"minor","fingerprint":"1b2e8c81e717c442","location":{"path":"main.sql","lines":{"begin":2,"end":2}},"check_name":"prefer-robust-stmts"},{"description":"Using 32-bit integer fields can result in hitting the max `int` limit. Suggestion: Use 64-bit integer values instead to prevent hitting this limit.","severity":"minor","fingerprint":"2bed2a431803b811","location":{"path":"main.sql","lines":{"begin":2,"end":2}},"check_name":"prefer-bigint-over-int"}]"#); } #[test] diff --git a/crates/squawk/src/snapshots/example.svg b/crates/squawk/src/snapshots/example.svg index 06f67499..0941e31a 100644 --- a/crates/squawk/src/snapshots/example.svg +++ b/crates/squawk/src/snapshots/example.svg @@ -106,7 +106,7 @@ ╰╴ ++++++++++++ - warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow operations + warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow SHARE lock operations ╭▸ ../../example.sql:10:1 @@ -118,7 +118,7 @@ - help: Configure a `lock_timeout` before this statement. + help: Configure a `lock_timeout` before this statement. Statement requires: SHARE lock; blocking: writes, schema changes. ╭╴ diff --git a/crates/squawk/src/snapshots/squawk__reporter__test_reporter__display_violations_tty.snap b/crates/squawk/src/snapshots/squawk__reporter__test_reporter__display_violations_tty.snap index c38569ea..ea6e5cee 100644 --- a/crates/squawk/src/snapshots/squawk__reporter__test_reporter__display_violations_tty.snap +++ b/crates/squawk/src/snapshots/squawk__reporter__test_reporter__display_violations_tty.snap @@ -2,13 +2,13 @@ source: crates/squawk/src/reporter.rs expression: "strip_ansi_codes(&String::from_utf8_lossy(&buff))" --- -warning[]8;;https://squawkhq.com/docs/require-timeout-settings\require-timeout-settings]8;;\]: Missing `set lock_timeout` before potentially slow operations +warning[]8;;https://squawkhq.com/docs/require-timeout-settings\require-timeout-settings]8;;\]: Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations ╭▸ main.sql:2:4 │ 2 │ ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL; │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ - ├ help: Configure a `lock_timeout` before this statement. + ├ help: Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes. ╭╴ 2 + set lock_timeout = '1s'; ╰╴ diff --git a/crates/squawk/src/snapshots/squawk__reporter__test_reporter__display_violations_tty_and_github_annotations.snap b/crates/squawk/src/snapshots/squawk__reporter__test_reporter__display_violations_tty_and_github_annotations.snap index d2dc3968..5db537a7 100644 --- a/crates/squawk/src/snapshots/squawk__reporter__test_reporter__display_violations_tty_and_github_annotations.snap +++ b/crates/squawk/src/snapshots/squawk__reporter__test_reporter__display_violations_tty_and_github_annotations.snap @@ -2,7 +2,7 @@ source: crates/squawk/src/reporter.rs expression: "strip_ansi_codes(&String::from_utf8_lossy(&buff))" --- -::warning file=main.sql,line=2,col=3,endLine=2,endColumn=63,title=require-timeout-settings::Missing `set lock_timeout` before potentially slow operations +::warning file=main.sql,line=2,col=3,endLine=2,endColumn=63,title=require-timeout-settings::Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations ::warning file=main.sql,line=2,col=3,endLine=2,endColumn=63,title=require-timeout-settings::Missing `set statement_timeout` before potentially slow operations ::warning file=main.sql,line=2,col=29,endLine=2,endColumn=62,title=adding-required-field::Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. ::warning file=main.sql,line=2,col=29,endLine=2,endColumn=62,title=prefer-robust-stmts::Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through. @@ -10,13 +10,13 @@ expression: "strip_ansi_codes(&String::from_utf8_lossy(&buff))" ::warning file=main.sql,line=5,col=2,endLine=6,endColumn=20,title=adding-required-field::Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. ::warning file=main.sql,line=5,col=2,endLine=6,endColumn=20,title=prefer-robust-stmts::Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through. ::warning file=main.sql,line=6,col=4,endLine=6,endColumn=11,title=prefer-bigint-over-int::Using 32-bit integer fields can result in hitting the max `int` limit. -warning[]8;;https://squawkhq.com/docs/require-timeout-settings\require-timeout-settings]8;;\]: Missing `set lock_timeout` before potentially slow operations +warning[]8;;https://squawkhq.com/docs/require-timeout-settings\require-timeout-settings]8;;\]: Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations ╭▸ main.sql:2:4 │ 2 │ ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL; │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ - ├ help: Configure a `lock_timeout` before this statement. + ├ help: Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes. ╭╴ 2 + set lock_timeout = '1s'; ╰╴ diff --git a/crates/squawk/src/snapshots/squawk__reporter__test_reporter__span_offsets.snap b/crates/squawk/src/snapshots/squawk__reporter__test_reporter__span_offsets.snap index 3eb082b5..1a0254dd 100644 --- a/crates/squawk/src/snapshots/squawk__reporter__test_reporter__span_offsets.snap +++ b/crates/squawk/src/snapshots/squawk__reporter__test_reporter__span_offsets.snap @@ -12,9 +12,9 @@ CheckReport { column: 3, range: 5..65, level: Warning, - message: "Missing `set lock_timeout` before potentially slow operations", + message: "Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations", help: Some( - "Configure a `lock_timeout` before this statement.", + "Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes.", ), rule_name: "require-timeout-settings", column_end: 63, diff --git a/crates/squawk_linter/Cargo.toml b/crates/squawk_linter/Cargo.toml index fa6de939..01db657b 100644 --- a/crates/squawk_linter/Cargo.toml +++ b/crates/squawk_linter/Cargo.toml @@ -26,6 +26,7 @@ rustc-hash.workspace = true [dev-dependencies] insta.workspace = true +tabled.workspace = true [lints] workspace = true diff --git a/crates/squawk_linter/src/ignore.rs b/crates/squawk_linter/src/ignore.rs index e5e048b7..18cdd0c3 100644 --- a/crates/squawk_linter/src/ignore.rs +++ b/crates/squawk_linter/src/ignore.rs @@ -302,10 +302,10 @@ create table users ( [ Violation { code: RequireTimeoutSettings, - message: "Missing `set lock_timeout` before potentially slow operations", + message: "Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations", text_range: 0..32, help: Some( - "Configure a `lock_timeout` before this statement.", + "Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes.", ), fix: Some( Fix { @@ -569,7 +569,7 @@ alter table t2 drop column c2 cascade; ), ( RequireTimeoutSettings, - "Missing `set lock_timeout` before potentially slow operations", + "Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations", ), ( RequireTimeoutSettings, diff --git a/crates/squawk_linter/src/rules/require_timeout_settings.rs b/crates/squawk_linter/src/rules/require_timeout_settings.rs index a026601a..e065c2e5 100644 --- a/crates/squawk_linter/src/rules/require_timeout_settings.rs +++ b/crates/squawk_linter/src/rules/require_timeout_settings.rs @@ -41,26 +41,223 @@ enum ReportOnce { Reported, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LockImpact(u8); + +impl LockImpact { + const READS: u8 = 0b001; + const SCHEMA_CHANGES: u8 = 0b100; + const WRITES: u8 = 0b010; + + const fn new(blocks: u8) -> Self { + Self(blocks) + } + + fn blocked(self) -> Vec<&'static str> { + let mut items = vec![]; + for (bit, label) in [ + (Self::READS, "reads"), + (Self::WRITES, "writes"), + (Self::SCHEMA_CHANGES, "schema changes"), + ] { + if self.0 & bit != 0 { + items.push(label); + } + } + items + } +} + /// The lock a statement takes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LockKind { - Unknown, + AccessExclusive, + AccessShare, + Exclusive, + RowExclusive, + RowShare, + Share, + ShareRowExclusive, ShareUpdateExclusive, + Unknown, } impl fmt::Display for LockKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - LockKind::Unknown => Ok(()), + LockKind::AccessExclusive => f.write_str("ACCESS EXCLUSIVE"), + LockKind::AccessShare => f.write_str("ACCESS SHARE"), + LockKind::Exclusive => f.write_str("EXCLUSIVE"), + LockKind::RowExclusive => f.write_str("ROW EXCLUSIVE"), + LockKind::RowShare => f.write_str("ROW SHARE"), + LockKind::Share => f.write_str("SHARE"), + LockKind::ShareRowExclusive => f.write_str("SHARE ROW EXCLUSIVE"), LockKind::ShareUpdateExclusive => f.write_str("SHARE UPDATE EXCLUSIVE"), + LockKind::Unknown => Ok(()), } } } impl LockKind { + fn from_lock_mode(lock_mode: ast::LockMode) -> LockKind { + match lock_mode { + ast::LockMode::AccessExclusive(_) => LockKind::AccessExclusive, + ast::LockMode::AccessShare(_) => LockKind::AccessShare, + ast::LockMode::Exclusive(_) => LockKind::Exclusive, + ast::LockMode::RowExclusive(_) => LockKind::RowExclusive, + ast::LockMode::RowShare(_) => LockKind::RowShare, + ast::LockMode::Share(_) => LockKind::Share, + ast::LockMode::ShareRowExclusive(_) => LockKind::ShareRowExclusive, + ast::LockMode::ShareUpdateExclusive(_) => LockKind::ShareUpdateExclusive, + } + } + + fn strength(self) -> u8 { + match self { + LockKind::Unknown => 0, + LockKind::AccessShare => 1, + LockKind::RowShare => 2, + LockKind::RowExclusive => 3, + LockKind::ShareUpdateExclusive => 4, + LockKind::Share => 5, + LockKind::ShareRowExclusive => 6, + LockKind::Exclusive => 7, + LockKind::AccessExclusive => 8, + } + } + + fn from_alter_table_action(action: &ast::AlterTableAction) -> LockKind { + match action { + ast::AlterTableAction::ClusterOn(_) + | ast::AlterTableAction::ResetOptions(_) + | ast::AlterTableAction::SetOptions(_) + | ast::AlterTableAction::SetWithoutCluster(_) + | ast::AlterTableAction::ValidateConstraint(_) => LockKind::ShareUpdateExclusive, + ast::AlterTableAction::DetachPartition(_) => LockKind::AccessExclusive, + ast::AlterTableAction::AddConstraint(add_constraint) => { + if let Some(ast::Constraint::ForeignKeyConstraint(_)) = add_constraint.constraint() + { + LockKind::ShareRowExclusive + } else { + LockKind::AccessExclusive + } + } + ast::AlterTableAction::DisableTrigger(_) + | ast::AlterTableAction::EnableAlwaysTrigger(_) + | ast::AlterTableAction::EnableReplicaTrigger(_) + | ast::AlterTableAction::EnableTrigger(_) => LockKind::ShareRowExclusive, + ast::AlterTableAction::AlterColumn(alter_column) => match alter_column.option() { + Some( + ast::AlterColumnOption::ResetOptions(_) + | ast::AlterColumnOption::SetOptions(_) + | ast::AlterColumnOption::SetStatistics(_), + ) => LockKind::ShareUpdateExclusive, + Some( + ast::AlterColumnOption::AddGenerated(_) + | ast::AlterColumnOption::DropDefault(_) + | ast::AlterColumnOption::DropExpression(_) + | ast::AlterColumnOption::DropIdentity(_) + | ast::AlterColumnOption::DropNotNull(_) + | ast::AlterColumnOption::Inherit(_) + | ast::AlterColumnOption::NoInherit(_) + | ast::AlterColumnOption::Restart(_) + | ast::AlterColumnOption::SetCompression(_) + | ast::AlterColumnOption::SetDefault(_) + | ast::AlterColumnOption::SetExpression(_) + | ast::AlterColumnOption::SetGenerated(_) + | ast::AlterColumnOption::SetGeneratedOptions(_) + | ast::AlterColumnOption::SetNotNull(_) + | ast::AlterColumnOption::SetOptionsList(_) + | ast::AlterColumnOption::SetSequenceOption(_) + | ast::AlterColumnOption::SetStorage(_) + | ast::AlterColumnOption::SetType(_), + ) + | None => LockKind::AccessExclusive, + }, + ast::AlterTableAction::AddColumn(_) + | ast::AlterTableAction::AlterConstraint(_) + | ast::AlterTableAction::AttachPartition(_) + | ast::AlterTableAction::DisableRls(_) + | ast::AlterTableAction::DisableRule(_) + | ast::AlterTableAction::DropColumn(_) + | ast::AlterTableAction::DropConstraint(_) + | ast::AlterTableAction::EnableAlwaysRule(_) + | ast::AlterTableAction::EnableReplicaRule(_) + | ast::AlterTableAction::EnableRls(_) + | ast::AlterTableAction::EnableRule(_) + | ast::AlterTableAction::ForceRls(_) + | ast::AlterTableAction::InheritTable(_) + | ast::AlterTableAction::MergePartitions(_) + | ast::AlterTableAction::NoForceRls(_) + | ast::AlterTableAction::NoInheritTable(_) + | ast::AlterTableAction::NotOf(_) + | ast::AlterTableAction::OfType(_) + | ast::AlterTableAction::OptionItemList(_) + | ast::AlterTableAction::OwnerTo(_) + | ast::AlterTableAction::RenameColumn(_) + | ast::AlterTableAction::RenameConstraint(_) + | ast::AlterTableAction::RenameTo(_) + | ast::AlterTableAction::ReplicaIdentity(_) + | ast::AlterTableAction::SetAccessMethod(_) + | ast::AlterTableAction::SetLogged(_) + | ast::AlterTableAction::SetSchema(_) + | ast::AlterTableAction::SetTablespace(_) + | ast::AlterTableAction::SetUnlogged(_) + | ast::AlterTableAction::SetWithoutOids(_) + | ast::AlterTableAction::SplitPartition(_) => LockKind::AccessExclusive, + } + } + fn from(stmt: &ast::Stmt) -> LockKind { match stmt { + ast::Stmt::AlterTable(alter_table) => alter_table + .actions() + .map(|action| Self::from_alter_table_action(&action)) + .max_by_key(|lock| lock.strength()) + .unwrap_or(LockKind::AccessExclusive), + ast::Stmt::Cluster(_) => LockKind::AccessExclusive, ast::Stmt::CommentOn(_) => LockKind::ShareUpdateExclusive, + ast::Stmt::CreateIndex(create_index) => { + if create_index.concurrently_token().is_some() { + LockKind::ShareUpdateExclusive + } else { + LockKind::Share + } + } + ast::Stmt::DropIndex(drop_index) => { + if drop_index.concurrently_token().is_some() { + LockKind::ShareUpdateExclusive + } else { + LockKind::AccessExclusive + } + } + ast::Stmt::DropTable(_) | ast::Stmt::DropView(_) => LockKind::AccessExclusive, + ast::Stmt::Lock(lock) => lock + .lock_mode() + .map(Self::from_lock_mode) + .unwrap_or(LockKind::AccessExclusive), + ast::Stmt::Refresh(refresh) => { + if refresh.concurrently_token().is_some() { + LockKind::Exclusive + } else { + LockKind::AccessExclusive + } + } + ast::Stmt::Reindex(reindex) => { + if reindex.is_concurrently() { + LockKind::ShareUpdateExclusive + } else { + LockKind::AccessExclusive + } + } + ast::Stmt::Truncate(_) => LockKind::AccessExclusive, + ast::Stmt::Vacuum(vacuum) => { + if vacuum.is_full() { + LockKind::AccessExclusive + } else { + LockKind::ShareUpdateExclusive + } + } _ => LockKind::Unknown, } } @@ -74,14 +271,37 @@ impl LockKind { } } - fn help(self) -> &'static str { + fn impact(self) -> Option { match self { - LockKind::Unknown => "Configure a `lock_timeout` before this statement.", - LockKind::ShareUpdateExclusive => { - "Configure a `lock_timeout` before this statement. It takes a SHARE UPDATE \ - EXCLUSIVE lock. It will block other schema changes (VACUUM, ANALYZE, index \ - builds) but not reads or writes." + LockKind::AccessExclusive => Some(LockImpact::new( + LockImpact::READS | LockImpact::WRITES | LockImpact::SCHEMA_CHANGES, + )), + LockKind::AccessShare => Some(LockImpact::new(LockImpact::SCHEMA_CHANGES)), + LockKind::Exclusive | LockKind::Share | LockKind::ShareRowExclusive => Some( + LockImpact::new(LockImpact::WRITES | LockImpact::SCHEMA_CHANGES), + ), + LockKind::RowExclusive | LockKind::RowShare | LockKind::ShareUpdateExclusive => { + Some(LockImpact::new(LockImpact::SCHEMA_CHANGES)) } + LockKind::Unknown => None, + } + } + + fn help(self) -> String { + let help = "Configure a `lock_timeout` before this statement."; + let name = self.to_string(); + let Some(impact) = self.impact() else { + return help.to_string(); + }; + + let blocked = impact.blocked(); + if blocked.is_empty() { + format!("{help} Statement requires: {name} lock.") + } else { + format!( + "{help} Statement requires: {name} lock; blocking: {}.", + blocked.join(", ") + ) } } } @@ -126,7 +346,7 @@ pub(crate) fn require_timeout_settings(ctx: &mut Linter, parse: &Parse String { + let mut builder = Builder::default(); + builder.push_record(["sql", "lock"]); + + for sql in cases { + let file = SourceFile::parse(sql); + assert_eq!(file.errors(), vec![]); + let stmt = file.tree().stmts().next().expect("expected statement"); + let lock = super::LockKind::from(&stmt); + builder.push_record([sql.to_string(), format!("{lock:?}")]); + } + + let mut table = builder.build(); + table.with(Style::psql()); + table.to_string() + } + + #[test] + fn lock_kind_for_statement_variants() { + let cases = [ + "ALTER TABLE t ADD COLUMN c int;", + "CLUSTER t;", + "COMMENT ON TABLE t IS 'x';", + "CREATE INDEX idx ON t (c);", + "CREATE INDEX CONCURRENTLY idx ON t (c);", + "DROP INDEX idx;", + "DROP INDEX CONCURRENTLY idx;", + "DROP TABLE t;", + "DROP VIEW v;", + "REFRESH MATERIALIZED VIEW mv;", + "REFRESH MATERIALIZED VIEW CONCURRENTLY mv;", + "REINDEX INDEX idx;", + "REINDEX INDEX CONCURRENTLY idx;", + "TRUNCATE t;", + "CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');", + ]; + + assert_snapshot!(lock_kinds(&cases), @" + sql | lock + --------------------------------------------------+---------------------- + ALTER TABLE t ADD COLUMN c int; | AccessExclusive + CLUSTER t; | AccessExclusive + COMMENT ON TABLE t IS 'x'; | ShareUpdateExclusive + CREATE INDEX idx ON t (c); | Share + CREATE INDEX CONCURRENTLY idx ON t (c); | ShareUpdateExclusive + DROP INDEX idx; | AccessExclusive + DROP INDEX CONCURRENTLY idx; | ShareUpdateExclusive + DROP TABLE t; | AccessExclusive + DROP VIEW v; | AccessExclusive + REFRESH MATERIALIZED VIEW mv; | AccessExclusive + REFRESH MATERIALIZED VIEW CONCURRENTLY mv; | Exclusive + REINDEX INDEX idx; | AccessExclusive + REINDEX INDEX CONCURRENTLY idx; | ShareUpdateExclusive + TRUNCATE t; | AccessExclusive + CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy'); | Unknown + "); + } + + #[test] + fn lock_kind_for_alter_table_variants() { + let cases = [ + "ALTER TABLE t ADD COLUMN c int;", + "ALTER TABLE t DROP COLUMN c;", + "ALTER TABLE t ALTER COLUMN c TYPE bigint;", + "ALTER TABLE t VALIDATE CONSTRAINT c;", + "ALTER TABLE t ALTER COLUMN c SET STATISTICS 100;", + "ALTER TABLE t ALTER COLUMN c SET (n_distinct = 5);", + "ALTER TABLE t SET (fillfactor = 70);", + "ALTER TABLE t RESET (fillfactor);", + "ALTER TABLE t CLUSTER ON idx;", + "ALTER TABLE t SET WITHOUT CLUSTER;", + "ALTER TABLE t DETACH PARTITION p;", + "ALTER TABLE t DETACH PARTITION p CONCURRENTLY;", + "ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES o (id);", + "ALTER TABLE t ADD CONSTRAINT ck CHECK (a > 0);", + "ALTER TABLE t DISABLE TRIGGER trg;", + "ALTER TABLE t ENABLE TRIGGER trg;", + "ALTER TABLE t ENABLE REPLICA TRIGGER trg;", + "ALTER TABLE t ENABLE ALWAYS TRIGGER trg;", + "ALTER TABLE t VALIDATE CONSTRAINT c, ADD COLUMN d int;", + ]; + + assert_snapshot!(lock_kinds(&cases), @" + sql | lock + --------------------------------------------------------------------+---------------------- + ALTER TABLE t ADD COLUMN c int; | AccessExclusive + ALTER TABLE t DROP COLUMN c; | AccessExclusive + ALTER TABLE t ALTER COLUMN c TYPE bigint; | AccessExclusive + ALTER TABLE t VALIDATE CONSTRAINT c; | ShareUpdateExclusive + ALTER TABLE t ALTER COLUMN c SET STATISTICS 100; | ShareUpdateExclusive + ALTER TABLE t ALTER COLUMN c SET (n_distinct = 5); | ShareUpdateExclusive + ALTER TABLE t SET (fillfactor = 70); | ShareUpdateExclusive + ALTER TABLE t RESET (fillfactor); | ShareUpdateExclusive + ALTER TABLE t CLUSTER ON idx; | ShareUpdateExclusive + ALTER TABLE t SET WITHOUT CLUSTER; | ShareUpdateExclusive + ALTER TABLE t DETACH PARTITION p; | AccessExclusive + ALTER TABLE t DETACH PARTITION p CONCURRENTLY; | AccessExclusive + ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES o (id); | ShareRowExclusive + ALTER TABLE t ADD CONSTRAINT ck CHECK (a > 0); | AccessExclusive + ALTER TABLE t DISABLE TRIGGER trg; | ShareRowExclusive + ALTER TABLE t ENABLE TRIGGER trg; | ShareRowExclusive + ALTER TABLE t ENABLE REPLICA TRIGGER trg; | ShareRowExclusive + ALTER TABLE t ENABLE ALWAYS TRIGGER trg; | ShareRowExclusive + ALTER TABLE t VALIDATE CONSTRAINT c, ADD COLUMN d int; | AccessExclusive + "); + } + + #[test] + fn lock_kind_for_explicit_lock_modes() { + let cases = [ + "LOCK TABLE t;", + "LOCK TABLE t IN ACCESS EXCLUSIVE MODE;", + "LOCK TABLE t IN ACCESS SHARE MODE;", + "LOCK TABLE t IN EXCLUSIVE MODE;", + "LOCK TABLE t IN ROW EXCLUSIVE MODE;", + "LOCK TABLE t IN ROW SHARE MODE;", + "LOCK TABLE t IN SHARE MODE;", + "LOCK TABLE t IN SHARE ROW EXCLUSIVE MODE;", + "LOCK TABLE t IN SHARE UPDATE EXCLUSIVE MODE;", + ]; + + assert_snapshot!(lock_kinds(&cases), @" + sql | lock + ----------------------------------------------+---------------------- + LOCK TABLE t; | AccessExclusive + LOCK TABLE t IN ACCESS EXCLUSIVE MODE; | AccessExclusive + LOCK TABLE t IN ACCESS SHARE MODE; | AccessShare + LOCK TABLE t IN EXCLUSIVE MODE; | Exclusive + LOCK TABLE t IN ROW EXCLUSIVE MODE; | RowExclusive + LOCK TABLE t IN ROW SHARE MODE; | RowShare + LOCK TABLE t IN SHARE MODE; | Share + LOCK TABLE t IN SHARE ROW EXCLUSIVE MODE; | ShareRowExclusive + LOCK TABLE t IN SHARE UPDATE EXCLUSIVE MODE; | ShareUpdateExclusive + "); + } + + #[test] + fn lock_kind_for_reindex_concurrently_options() { + let cases = [ + "REINDEX (CONCURRENTLY) INDEX idx;", + "REINDEX (CONCURRENTLY true) INDEX idx;", + "REINDEX (CONCURRENTLY false) INDEX idx;", + "REINDEX (CONCURRENTLY 'false') INDEX idx;", + "REINDEX (CONCURRENTLY E'false') INDEX idx;", + "REINDEX (CONCURRENTLY U&'false') INDEX idx;", + "REINDEX (CONCURRENTLY off) INDEX idx;", + "REINDEX (CONCURRENTLY 0) INDEX idx;", + ]; + + assert_snapshot!(lock_kinds(&cases), @" + sql | lock + ---------------------------------------------+---------------------- + REINDEX (CONCURRENTLY) INDEX idx; | ShareUpdateExclusive + REINDEX (CONCURRENTLY true) INDEX idx; | ShareUpdateExclusive + REINDEX (CONCURRENTLY false) INDEX idx; | AccessExclusive + REINDEX (CONCURRENTLY 'false') INDEX idx; | AccessExclusive + REINDEX (CONCURRENTLY E'false') INDEX idx; | AccessExclusive + REINDEX (CONCURRENTLY U&'false') INDEX idx; | AccessExclusive + REINDEX (CONCURRENTLY off) INDEX idx; | AccessExclusive + REINDEX (CONCURRENTLY 0) INDEX idx; | AccessExclusive + "); + } + + #[test] + fn lock_kind_for_vacuum_full_options() { + let cases = [ + "VACUUM t;", + "VACUUM FULL t;", + "VACUUM (FULL) t;", + "VACUUM (FULL true) t;", + "VACUUM (FULL false) t;", + "VACUUM (FULL 'false') t;", + "VACUUM (FULL E'false') t;", + "VACUUM (FULL U&'false') t;", + "VACUUM (FULL off) t;", + "VACUUM (FULL 0) t;", + ]; + + assert_snapshot!(lock_kinds(&cases), @" + sql | lock + ----------------------------+---------------------- + VACUUM t; | ShareUpdateExclusive + VACUUM FULL t; | AccessExclusive + VACUUM (FULL) t; | AccessExclusive + VACUUM (FULL true) t; | AccessExclusive + VACUUM (FULL false) t; | ShareUpdateExclusive + VACUUM (FULL 'false') t; | ShareUpdateExclusive + VACUUM (FULL E'false') t; | ShareUpdateExclusive + VACUUM (FULL U&'false') t; | ShareUpdateExclusive + VACUUM (FULL off) t; | ShareUpdateExclusive + VACUUM (FULL 0) t; | ShareUpdateExclusive + "); + } + #[test] fn err_missing_both_timeouts() { let sql = r#" ALTER TABLE t ADD COLUMN c BOOLEAN; "#; assert_snapshot!(lint_errors(sql, Rule::RequireTimeoutSettings), @" - warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow operations + warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations ╭▸ 2 │ ALTER TABLE t ADD COLUMN c BOOLEAN; │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ - ├ help: Configure a `lock_timeout` before this statement. + ├ help: Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes. ╭╴ 2 + set lock_timeout = '1s'; ╰╴ @@ -198,12 +614,12 @@ SET statement_timeout = '5s'; ALTER TABLE t ADD COLUMN c BOOLEAN; "#; assert_snapshot!(lint_errors(sql, Rule::RequireTimeoutSettings), @" - warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow operations + warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations ╭▸ 3 │ ALTER TABLE t ADD COLUMN c BOOLEAN; │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ - ├ help: Configure a `lock_timeout` before this statement. + ├ help: Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes. ╭╴ 2 + set lock_timeout = '1s'; ╰╴ @@ -265,12 +681,12 @@ SET foo.statement_timeout = '5s'; ALTER TABLE t ADD COLUMN c BOOLEAN; "#; assert_snapshot!(lint_errors(sql, Rule::RequireTimeoutSettings), @" - warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow operations + warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations ╭▸ 4 │ ALTER TABLE t ADD COLUMN c BOOLEAN; │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ - ├ help: Configure a `lock_timeout` before this statement. + ├ help: Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes. ╭╴ 2 + set lock_timeout = '1s'; ╰╴ @@ -293,12 +709,12 @@ SET lock_timeout = '1s'; SET statement_timeout = '5s'; "#; assert_snapshot!(lint_errors(sql, Rule::RequireTimeoutSettings), @" - warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow operations + warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations ╭▸ 2 │ ALTER TABLE t ADD COLUMN c BOOLEAN; │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ - ├ help: Configure a `lock_timeout` before this statement. + ├ help: Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes. ╭╴ 2 + set lock_timeout = '1s'; ╰╴ @@ -355,7 +771,7 @@ COMMENT ON COLUMN t.c IS 'an opaque id'; 2 │ COMMENT ON COLUMN t.c IS 'an opaque id'; │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ - ├ help: Configure a `lock_timeout` before this statement. It takes a SHARE UPDATE EXCLUSIVE lock. It will block other schema changes (VACUUM, ANALYZE, index builds) but not reads or writes. + ├ help: Configure a `lock_timeout` before this statement. Statement requires: SHARE UPDATE EXCLUSIVE lock; blocking: schema changes. ╭╴ 2 + set lock_timeout = '1s'; ╰╴ @@ -371,6 +787,87 @@ COMMENT ON COLUMN t.c IS 'an opaque id'; "); } + #[test] + fn err_reindex_concurrently_false_uses_non_concurrent_lock() { + let sql = r#" +REINDEX (CONCURRENTLY false) INDEX idx; + "#; + assert_snapshot!(lint_errors(sql, Rule::RequireTimeoutSettings), @" + warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations + ╭▸ + 2 │ REINDEX (CONCURRENTLY false) INDEX idx; + │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + │ + ├ help: Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes. + ╭╴ + 2 + set lock_timeout = '1s'; + ╰╴ + warning[require-timeout-settings]: Missing `set statement_timeout` before potentially slow operations + ╭▸ + 2 │ REINDEX (CONCURRENTLY false) INDEX idx; + │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + │ + ├ help: Configure a `statement_timeout` before this statement + ╭╴ + 2 + set statement_timeout = '5s'; + ╰╴ + "); + } + + #[test] + fn err_vacuum_full_false_uses_non_full_lock() { + let sql = r#" +VACUUM (FULL false) t; + "#; + assert_snapshot!(lint_errors(sql, Rule::RequireTimeoutSettings), @" + warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow SHARE UPDATE EXCLUSIVE lock operations + ╭▸ + 2 │ VACUUM (FULL false) t; + │ ━━━━━━━━━━━━━━━━━━━━━━ + │ + ├ help: Configure a `lock_timeout` before this statement. Statement requires: SHARE UPDATE EXCLUSIVE lock; blocking: schema changes. + ╭╴ + 2 + set lock_timeout = '1s'; + ╰╴ + warning[require-timeout-settings]: Missing `set statement_timeout` before potentially slow operations + ╭▸ + 2 │ VACUUM (FULL false) t; + │ ━━━━━━━━━━━━━━━━━━━━━━ + │ + ├ help: Configure a `statement_timeout` before this statement + ╭╴ + 2 + set statement_timeout = '5s'; + ╰╴ + "); + } + + #[test] + fn err_lock_access_share_uses_correct_article() { + let sql = r#" +LOCK TABLE t IN ACCESS SHARE MODE; + "#; + assert_snapshot!(lint_errors(sql, Rule::RequireTimeoutSettings), @" + warning[require-timeout-settings]: Missing `set lock_timeout` before potentially slow ACCESS SHARE lock operations + ╭▸ + 2 │ LOCK TABLE t IN ACCESS SHARE MODE; + │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + │ + ├ help: Configure a `lock_timeout` before this statement. Statement requires: ACCESS SHARE lock; blocking: schema changes. + ╭╴ + 2 + set lock_timeout = '1s'; + ╰╴ + warning[require-timeout-settings]: Missing `set statement_timeout` before potentially slow operations + ╭▸ + 2 │ LOCK TABLE t IN ACCESS SHARE MODE; + │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + │ + ├ help: Configure a `statement_timeout` before this statement + ╭╴ + 2 + set statement_timeout = '5s'; + ╰╴ + "); + } + #[test] fn ok_other_ddl_with_timeouts() { let sql = r#" diff --git a/crates/squawk_syntax/src/ast/node_ext.rs b/crates/squawk_syntax/src/ast/node_ext.rs index bf149555..f1c29819 100644 --- a/crates/squawk_syntax/src/ast/node_ext.rs +++ b/crates/squawk_syntax/src/ast/node_ext.rs @@ -539,6 +539,23 @@ fn is_falsey_vacuum_option_value(value: &ast::VacuumOptionValue) -> bool { .is_some_and(|token| is_falsey_token(&token)) } +impl ast::Reindex { + pub fn is_concurrently(&self) -> bool { + self.concurrently_token().is_some() + || self.reindex_option_list().is_some_and(|options| { + options.reindex_options().any(|option| { + option.concurrently_token().is_some() + && !option.literal().is_some_and(|literal| { + literal + .syntax() + .first_token() + .is_some_and(|token| is_falsey_token(&token)) + }) + }) + }) + } +} + impl ast::Vacuum { pub fn is_full(&self) -> bool { self.full_token().is_some()