Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- linter: default rollback compatibility rules: ban-drop-schema, ban-drop-sequence, ban-drop-domain, ban-drop-constraint, ban-drop-generated-expression, ban-drop-extension, ban-alter-identity, renaming-object, ban-set-schema

### Changed

- linter: extend adding-not-nullable-field and ban-drop-not-null to domains and foreign tables; ban-drop-default to domains, foreign tables, and views; ban-drop-column and changing-column-type to foreign tables and composite attributes; renaming-column to foreign tables, composite attributes, views, and materialized views
- linter: extend ban-drop-function to aggregates and routines, ban-drop-table to foreign tables, and ban-drop-type to operators, operator classes, operator families, and casts

## v2.67.0 - 2026-10-04

### Added
Expand Down
90 changes: 90 additions & 0 deletions crates/squawk_linter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ use rules::require_enum_value_ordering;
use rules::require_table_schema;
use rules::require_timeout_settings;
use rules::transaction_nesting;
use rules::{
ban_alter_identity, ban_drop_constraint, ban_drop_domain, ban_drop_extension,
ban_drop_generated_expression, ban_drop_schema, ban_drop_sequence, ban_set_schema,
renaming_object,
};
// xtask:new-rule:rule-import

#[derive(Debug, PartialEq, Clone, Copy, Hash, Eq, Sequence)]
Expand Down Expand Up @@ -117,6 +122,15 @@ pub enum Rule {
BanDropType,
BanDropDefault,
BanDropTrigger,
BanDropSchema,
BanDropSequence,
BanDropDomain,
BanDropConstraint,
BanDropGeneratedExpression,
RenamingObject,
BanSetSchema,
BanAlterIdentity,
BanDropExtension,
// xtask:new-rule:error-name
}

Expand Down Expand Up @@ -195,6 +209,15 @@ impl TryFrom<&str> for Rule {
"ban-drop-type" => Ok(Rule::BanDropType),
"ban-drop-default" => Ok(Rule::BanDropDefault),
"ban-drop-trigger" => Ok(Rule::BanDropTrigger),
"ban-drop-schema" => Ok(Rule::BanDropSchema),
"ban-drop-sequence" => Ok(Rule::BanDropSequence),
"ban-drop-domain" => Ok(Rule::BanDropDomain),
"ban-drop-constraint" => Ok(Rule::BanDropConstraint),
"ban-drop-generated-expression" => Ok(Rule::BanDropGeneratedExpression),
"renaming-object" => Ok(Rule::RenamingObject),
"ban-set-schema" => Ok(Rule::BanSetSchema),
"ban-alter-identity" => Ok(Rule::BanAlterIdentity),
"ban-drop-extension" => Ok(Rule::BanDropExtension),
// xtask:new-rule:str-name
_ => Err(format!("Unknown violation name: {s}")),
}
Expand Down Expand Up @@ -271,6 +294,15 @@ impl fmt::Display for Rule {
Rule::BanDropType => "ban-drop-type",
Rule::BanDropDefault => "ban-drop-default",
Rule::BanDropTrigger => "ban-drop-trigger",
Rule::BanDropSchema => "ban-drop-schema",
Rule::BanDropSequence => "ban-drop-sequence",
Rule::BanDropDomain => "ban-drop-domain",
Rule::BanDropConstraint => "ban-drop-constraint",
Rule::BanDropGeneratedExpression => "ban-drop-generated-expression",
Rule::RenamingObject => "renaming-object",
Rule::BanSetSchema => "ban-set-schema",
Rule::BanAlterIdentity => "ban-alter-identity",
Rule::BanDropExtension => "ban-drop-extension",
// xtask:new-rule:variant-to-name
};
write!(f, "{val}")
Expand Down Expand Up @@ -540,6 +572,33 @@ impl Linter {
if self.rules.contains(&Rule::BanDropTrigger) {
ban_drop_trigger(self, file);
}
if self.rules.contains(&Rule::BanDropSchema) {
ban_drop_schema(self, file);
}
if self.rules.contains(&Rule::BanDropSequence) {
ban_drop_sequence(self, file);
}
if self.rules.contains(&Rule::BanDropDomain) {
ban_drop_domain(self, file);
}
if self.rules.contains(&Rule::BanDropConstraint) {
ban_drop_constraint(self, file);
}
if self.rules.contains(&Rule::BanDropGeneratedExpression) {
ban_drop_generated_expression(self, file);
}
if self.rules.contains(&Rule::RenamingObject) {
renaming_object(self, file);
}
if self.rules.contains(&Rule::BanSetSchema) {
ban_set_schema(self, file);
}
if self.rules.contains(&Rule::BanAlterIdentity) {
ban_alter_identity(self, file);
}
if self.rules.contains(&Rule::BanDropExtension) {
ban_drop_extension(self, file);
}
// xtask:new-rule:rule-call

// locate any ignores in the file
Expand Down Expand Up @@ -687,4 +746,35 @@ mod tests {
assert!(linter.rules.contains(&Rule::RequireLockTimeout));
assert!(!linter.rules.contains(&Rule::RequireStatementTimeout));
}

#[test]
fn compatibility_rules_are_enabled_by_default_and_can_be_excluded() {
for (rule, sql) in [
(Rule::BanDropConstraint, "ALTER TABLE t DROP CONSTRAINT c;"),
(
Rule::BanAlterIdentity,
"ALTER TABLE t ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY;",
),
(
Rule::BanDropGeneratedExpression,
"ALTER TABLE t ALTER COLUMN c DROP EXPRESSION;",
),
(Rule::BanDropExtension, "DROP EXTENSION hstore;"),
] {
let parse = SourceFile::parse(sql);
assert!(parse.errors().is_empty());
assert!(
Linter::with_default_rules()
.lint(&parse, sql)
.iter()
.any(|v| v.code == rule)
);
assert!(
!Linter::with_rules(&[], &[rule])
.lint(&parse, sql)
.iter()
.any(|v| v.code == rule)
);
}
}
}
8 changes: 8 additions & 0 deletions crates/squawk_linter/src/rules/adding_not_null_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@ ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL;
assert_snapshot!(lint_errors(sql, Rule::AddingNotNullableField));
}

#[test]
fn domain_and_foreign_table_set_not_null_are_not_locking_warnings() {
lint_ok(
"ALTER DOMAIN d SET NOT NULL; ALTER FOREIGN TABLE ft ALTER COLUMN c SET NOT NULL;",
Rule::AddingNotNullableField,
);
}

#[test]
fn adding_field_that_is_not_nullable() {
let sql = r#"
Expand Down
70 changes: 70 additions & 0 deletions crates/squawk_linter/src/rules/ban_alter_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use crate::{Linter, Rule, Violation};
use squawk_syntax::{
Parse, SourceFile,
ast::{self, AstNode},
};

pub(crate) fn ban_alter_identity(ctx: &mut Linter, parse: &Parse<SourceFile>) {
for stmt in parse.tree().stmts() {
if let ast::Stmt::AlterTable(table) = stmt {
for action in table.actions() {
if let ast::AlterTableAction::AlterColumn(column) = action {
if let Some(option) = column.option() {
match option {
ast::AlterColumnOption::AddGenerated(node) => {
ctx.report(Violation::for_node(Rule::BanAlterIdentity, "Changing column identity may break inserts from existing clients.".into(), node.syntax()));
}
ast::AlterColumnOption::DropIdentity(node) => {
ctx.report(Violation::for_node(Rule::BanAlterIdentity, "Changing column identity may break inserts from existing clients.".into(), node.syntax()));
}
ast::AlterColumnOption::SetGenerated(node)
if matches!(
node.generated_when(),
Some(ast::GeneratedWhen::GeneratedAlways(_))
) =>
{
ctx.report(Violation::for_node(Rule::BanAlterIdentity, "Changing column identity may break inserts from existing clients.".into(), node.syntax()));
}
ast::AlterColumnOption::SetGeneratedOptions(options) => {
for option in options.set_generated_options() {
if let ast::SetGeneratedOption::SetGenerated(node) = option {
if matches!(
node.generated_when(),
Some(ast::GeneratedWhen::GeneratedAlways(_))
) {
ctx.report(Violation::for_node(Rule::BanAlterIdentity, "Changing column identity may break inserts from existing clients.".into(), node.syntax()));
}
}
}
}
_ => (),
}
}
}
}
}
}
}

#[cfg(test)]
mod test {
use crate::{
Rule,
test_utils::{lint_errors, lint_ok},
};
use insta::assert_snapshot;
#[test]
fn err() {
let sql = "ALTER TABLE t ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY; ALTER TABLE t ALTER COLUMN id DROP IDENTITY; ALTER TABLE t ALTER COLUMN id SET GENERATED ALWAYS;";
let errors = lint_errors(sql, Rule::BanAlterIdentity);
assert_eq!(errors.matches("warning[ban-alter-identity]").count(), 3);
assert_snapshot!(errors);
}
#[test]
fn ok() {
lint_ok(
"ALTER TABLE t ALTER COLUMN id SET DEFAULT 1; ALTER TABLE t ALTER COLUMN id SET GENERATED BY DEFAULT;",
Rule::BanAlterIdentity,
);
}
}
41 changes: 33 additions & 8 deletions crates/squawk_linter/src/rules/ban_drop_column.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,33 @@ use crate::{Linter, Rule, Violation};
pub(crate) fn ban_drop_column(ctx: &mut Linter, parse: &Parse<SourceFile>) {
let file = parse.tree();
for stmt in file.stmts() {
if let ast::Stmt::AlterTable(alter_table) = stmt {
for action in alter_table.actions() {
if let ast::AlterTableAction::DropColumn(drop_column) = action {
ctx.report(Violation::for_node(
Rule::BanDropColumn,
"Dropping a column may break existing clients.".into(),
drop_column.syntax(),
));
let actions: Vec<_> = match stmt {
ast::Stmt::AlterTable(table) => table.actions().collect(),
ast::Stmt::AlterForeignTable(table) => table.actions().collect(),
ast::Stmt::AlterType(ty) => {
if let Some(ast::AlterTypeAction::AlterTypeAttributeActionList(list)) = ty.action()
{
for action in list.actions() {
if let ast::AlterTypeAttributeAction::DropAttribute(node) = action {
ctx.report(Violation::for_node(
Rule::BanDropColumn,
"Dropping an attribute may break existing clients.".into(),
node.syntax(),
));
}
}
}
Vec::new()
}
_ => Vec::new(),
};
for action in actions {
if let ast::AlterTableAction::DropColumn(drop_column) = action {
ctx.report(Violation::for_node(
Rule::BanDropColumn,
"Dropping a column may break existing clients.".into(),
drop_column.syntax(),
));
}
}
}
Expand All @@ -36,4 +54,11 @@ ALTER TABLE "bar_tbl" DROP COLUMN "foo_col" CASCADE;
"#;
assert_snapshot!(lint_errors(sql, Rule::BanDropColumn));
}

#[test]
fn other_columns() {
let sql = "ALTER TYPE composite DROP ATTRIBUTE a; ALTER FOREIGN TABLE ft DROP COLUMN a;";
let errors = lint_errors(sql, Rule::BanDropColumn);
assert_eq!(errors.matches("warning[ban-drop-column]").count(), 2);
}
}
74 changes: 74 additions & 0 deletions crates/squawk_linter/src/rules/ban_drop_constraint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
use crate::{Linter, Rule, Violation};
use squawk_syntax::{
Parse, SourceFile,
ast::{self, AstNode},
};

pub(crate) fn ban_drop_constraint(ctx: &mut Linter, parse: &Parse<SourceFile>) {
for stmt in parse.tree().stmts() {
let actions: Vec<_> = match &stmt {
ast::Stmt::AlterTable(table) => table.actions().collect(),
ast::Stmt::AlterForeignTable(table) => table.actions().collect(),
_ => Vec::new(),
};
for action in actions {
match action {
ast::AlterTableAction::DropConstraint(node) => ctx.report(Violation::for_node(
Rule::BanDropConstraint,
"Dropping a constraint may remove a guarantee that existing clients assume."
.into(),
node.syntax(),
)),
ast::AlterTableAction::AlterConstraint(node) => {
for option in node.constraint_options() {
if let ast::ConstraintOption::NotEnforced(option) = option {
ctx.report(Violation::for_node(Rule::BanDropConstraint, "Disabling constraint enforcement may remove a guarantee that existing clients assume.".into(), option.syntax()));
}
}
}
_ => (),
}
}
if let ast::Stmt::AlterDomain(domain) = stmt {
if let Some(ast::AlterDomainAction::DropConstraint(node)) = domain.action() {
ctx.report(Violation::for_node(
Rule::BanDropConstraint,
"Dropping a constraint may remove a guarantee that existing clients assume."
.into(),
node.syntax(),
));
}
}
}
}

#[cfg(test)]
mod test {
use crate::{
Rule,
test_utils::{lint_errors, lint_ok},
};
use insta::assert_snapshot;
#[test]
fn err() {
let sql = "ALTER TABLE t DROP CONSTRAINT IF EXISTS c;";
assert_snapshot!(lint_errors(sql, Rule::BanDropConstraint));
}
#[test]
fn other_constraints() {
let sql = "ALTER DOMAIN d DROP CONSTRAINT c; ALTER TABLE t ALTER CONSTRAINT c NOT ENFORCED; ALTER FOREIGN TABLE ft DROP CONSTRAINT c;";
let errors = lint_errors(sql, Rule::BanDropConstraint);
assert_eq!(errors.matches("warning[ban-drop-constraint]").count(), 3);
}
#[test]
fn ok() {
lint_ok(
"ALTER TABLE t ADD CONSTRAINT c CHECK (id > 0);",
Rule::BanDropConstraint,
);
lint_ok(
"ALTER TABLE t ALTER CONSTRAINT c ENFORCED;",
Rule::BanDropConstraint,
);
}
}
Loading
Loading