pub mod locks;
use crate::diff::MigrationOp;
use crate::model::PgType;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LintOptions {
pub allow_destructive: bool,
pub is_production: bool,
}
impl LintOptions {
pub fn from_env(allow_destructive: bool) -> Self {
Self {
allow_destructive,
is_production: std::env::var("PGMOLD_PROD")
.map(|v| v == "1")
.unwrap_or(false),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LintSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintResult {
pub rule: &'static str,
pub severity: LintSeverity,
pub message: String,
}
pub fn lint_migration_plan(ops: &[MigrationOp], options: &LintOptions) -> Vec<LintResult> {
ops.iter().flat_map(|op| lint_op(op, options)).collect()
}
pub fn has_errors(results: &[LintResult]) -> bool {
results
.iter()
.any(|r| matches!(r.severity, LintSeverity::Error))
}
fn lint_op(op: &MigrationOp, options: &LintOptions) -> Vec<LintResult> {
let mut results = Vec::new();
match op {
MigrationOp::DropColumn { table, column } => {
if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_column",
severity: LintSeverity::Error,
message: format!(
"Dropping column {table}.{column} requires --allow-destructive flag"
),
});
}
}
MigrationOp::DropTable(name) => {
if options.is_production {
results.push(LintResult {
rule: "deny_drop_table_in_prod",
severity: LintSeverity::Error,
message: format!(
"Dropping table {name} is not allowed in production (PGMOLD_PROD=1)"
),
});
} else if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_table",
severity: LintSeverity::Error,
message: format!("Dropping table {name} requires --allow-destructive flag"),
});
}
}
MigrationOp::AlterColumn {
table,
column,
changes,
} => {
if let Some(ref new_type) = changes.data_type {
if is_type_narrowing(new_type) {
results.push(LintResult {
rule: "warn_type_narrowing",
severity: LintSeverity::Warning,
message: format!(
"Altering column {table}.{column} to a smaller type may cause data loss"
),
});
}
}
if changes.nullable == Some(false) {
results.push(LintResult {
rule: "warn_set_not_null",
severity: LintSeverity::Warning,
message: format!(
"Setting column {table}.{column} to NOT NULL may fail if existing rows have NULL values"
),
});
}
}
MigrationOp::DropView { name, materialized } => {
if !options.allow_destructive {
let (rule, view_type) = if *materialized {
("deny_drop_materialized_view", "materialized view")
} else {
("deny_drop_view", "view")
};
results.push(LintResult {
rule,
severity: LintSeverity::Error,
message: format!(
"Dropping {view_type} {name} requires --allow-destructive flag"
),
});
}
}
MigrationOp::DropEnum(name) => {
if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_enum",
severity: LintSeverity::Error,
message: format!("Dropping enum {name} requires --allow-destructive flag"),
});
}
}
MigrationOp::DropTrigger {
target_schema,
target_name,
name,
} => {
if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_trigger",
severity: LintSeverity::Error,
message: format!(
"Dropping trigger \"{target_schema}\".\"{target_name}\".{name} requires --allow-destructive flag"
),
});
}
}
MigrationOp::DropSequence(name) => {
if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_sequence",
severity: LintSeverity::Error,
message: format!(
"Dropping sequence \"{name}\" requires --allow-destructive flag"
),
});
}
}
MigrationOp::DropUniqueConstraint {
table,
constraint_name,
} => {
if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_unique_constraint",
severity: LintSeverity::Error,
message: format!(
"Dropping unique constraint \"{constraint_name}\" on \"{table}\" requires --allow-destructive flag"
),
});
}
}
MigrationOp::AlterSequence { name, changes } => {
if changes.restart.is_some() {
results.push(LintResult {
rule: "warn_sequence_restart",
severity: LintSeverity::Warning,
message: format!(
"Restarting sequence \"{name}\" may cause duplicate key violations"
),
});
}
}
MigrationOp::DropSchema(name) => {
if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_schema",
severity: LintSeverity::Error,
message: format!(
"Dropping schema \"{name}\" requires --allow-destructive flag"
),
});
}
}
MigrationOp::DropExtension(name) => {
if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_extension",
severity: LintSeverity::Error,
message: format!(
"Dropping extension \"{name}\" requires --allow-destructive flag"
),
});
}
}
MigrationOp::DropDomain(name) => {
if !options.allow_destructive {
results.push(LintResult {
rule: "deny_drop_domain",
severity: LintSeverity::Error,
message: format!(
"Dropping domain \"{name}\" requires --allow-destructive flag"
),
});
}
}
MigrationOp::CreateSchema(_)
| MigrationOp::CreateExtension(_)
| MigrationOp::CreateEnum(_)
| MigrationOp::AddEnumValue { .. }
| MigrationOp::CreateDomain(_)
| MigrationOp::AlterDomain { .. }
| MigrationOp::CreateTable(_)
| MigrationOp::CreatePartition(_)
| MigrationOp::DropPartition(_)
| MigrationOp::AddColumn { .. }
| MigrationOp::AddPrimaryKey { .. }
| MigrationOp::DropPrimaryKey { .. }
| MigrationOp::AddIndex { .. }
| MigrationOp::DropIndex { .. }
| MigrationOp::AddForeignKey { .. }
| MigrationOp::DropForeignKey { .. }
| MigrationOp::AddCheckConstraint { .. }
| MigrationOp::DropCheckConstraint { .. }
| MigrationOp::EnableRls { .. }
| MigrationOp::DisableRls { .. }
| MigrationOp::ForceRls { .. }
| MigrationOp::NoForceRls { .. }
| MigrationOp::CreatePolicy(_)
| MigrationOp::DropPolicy { .. }
| MigrationOp::AlterPolicy { .. }
| MigrationOp::CreateFunction(_)
| MigrationOp::DropFunction { .. }
| MigrationOp::AlterFunction { .. }
| MigrationOp::CreateView(_)
| MigrationOp::AlterView { .. }
| MigrationOp::CreateTrigger(_)
| MigrationOp::AlterTriggerEnabled { .. }
| MigrationOp::CreateSequence(_)
| MigrationOp::AlterOwner { .. }
| MigrationOp::SetColumnNotNull { .. }
| MigrationOp::GrantPrivileges { .. }
| MigrationOp::RevokePrivileges { .. }
| MigrationOp::AlterDefaultPrivileges { .. }
| MigrationOp::CreateVersionSchema { .. }
| MigrationOp::DropVersionSchema { .. }
| MigrationOp::CreateVersionView { .. }
| MigrationOp::DropVersionView { .. }
| MigrationOp::BackfillHint { .. }
| MigrationOp::SetComment { .. } => {}
}
results
}
fn is_type_narrowing(new_type: &PgType) -> bool {
matches!(
new_type,
PgType::SmallInt | PgType::Varchar(Some(_)) | PgType::Integer
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::ColumnChanges;
use crate::model::QualifiedName;
#[test]
fn blocks_drop_column_without_flag() {
let ops = vec![MigrationOp::DropColumn {
table: QualifiedName::new("public", "users"),
column: "email".to_string(),
}];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_column");
}
#[test]
fn allows_drop_column_with_flag() {
let ops = vec![MigrationOp::DropColumn {
table: QualifiedName::new("public", "users"),
column: "email".to_string(),
}];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
#[test]
fn blocks_drop_table_without_flag() {
let ops = vec![MigrationOp::DropTable("users".to_string())];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_table");
}
#[test]
fn blocks_drop_table_in_production() {
let ops = vec![MigrationOp::DropTable("users".to_string())];
let options = LintOptions {
allow_destructive: true,
is_production: true,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_table_in_prod");
}
#[test]
fn warns_on_type_narrowing() {
let ops = vec![MigrationOp::AlterColumn {
table: QualifiedName::new("public", "users"),
column: "name".to_string(),
changes: ColumnChanges {
data_type: Some(PgType::Varchar(Some(50))),
nullable: None,
default: None,
},
}];
let options = LintOptions::default();
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
assert_eq!(results[0].rule, "warn_type_narrowing");
assert!(matches!(results[0].severity, LintSeverity::Warning));
}
#[test]
fn warns_on_set_not_null() {
let ops = vec![MigrationOp::AlterColumn {
table: QualifiedName::new("public", "users"),
column: "bio".to_string(),
changes: ColumnChanges {
data_type: None,
nullable: Some(false),
default: None,
},
}];
let options = LintOptions::default();
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
assert_eq!(results[0].rule, "warn_set_not_null");
}
#[test]
fn has_errors_returns_false_for_warnings_only() {
let results = vec![LintResult {
rule: "warn_something",
severity: LintSeverity::Warning,
message: "Just a warning".to_string(),
}];
assert!(!has_errors(&results));
}
#[test]
fn blocks_drop_view_without_flag() {
let ops = vec![MigrationOp::DropView {
name: "active_users".to_string(),
materialized: false,
}];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_view");
}
#[test]
fn allows_drop_view_with_flag() {
let ops = vec![MigrationOp::DropView {
name: "active_users".to_string(),
materialized: false,
}];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
#[test]
fn blocks_drop_materialized_view_without_flag() {
let ops = vec![MigrationOp::DropView {
name: "user_stats".to_string(),
materialized: true,
}];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_materialized_view");
}
#[test]
fn blocks_drop_enum_without_flag() {
let ops = vec![MigrationOp::DropEnum("user_role".to_string())];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_enum");
}
#[test]
fn allows_drop_enum_with_flag() {
let ops = vec![MigrationOp::DropEnum("user_role".to_string())];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
#[test]
fn blocks_drop_trigger_without_flag() {
let ops = vec![MigrationOp::DropTrigger {
target_schema: "public".to_string(),
target_name: "users".to_string(),
name: "update_timestamp".to_string(),
}];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_trigger");
}
#[test]
fn allows_drop_trigger_with_flag() {
let ops = vec![MigrationOp::DropTrigger {
target_schema: "public".to_string(),
target_name: "users".to_string(),
name: "update_timestamp".to_string(),
}];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
#[test]
fn blocks_drop_sequence_without_flag() {
let ops = vec![MigrationOp::DropSequence("user_id_seq".to_string())];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_sequence");
}
#[test]
fn allows_drop_sequence_with_flag() {
let ops = vec![MigrationOp::DropSequence("user_id_seq".to_string())];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
#[test]
fn warns_on_sequence_restart() {
use crate::diff::SequenceChanges;
let ops = vec![MigrationOp::AlterSequence {
name: "user_id_seq".to_string(),
changes: SequenceChanges {
restart: Some(1),
..Default::default()
},
}];
let options = LintOptions::default();
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
assert_eq!(results[0].rule, "warn_sequence_restart");
assert!(matches!(results[0].severity, LintSeverity::Warning));
}
#[test]
fn allows_alter_sequence_without_restart() {
use crate::diff::SequenceChanges;
let ops = vec![MigrationOp::AlterSequence {
name: "user_id_seq".to_string(),
changes: SequenceChanges {
increment: Some(2),
..Default::default()
},
}];
let options = LintOptions::default();
let results = lint_migration_plan(&ops, &options);
assert!(results.is_empty());
}
#[test]
fn blocks_drop_unique_constraint_without_flag() {
let ops = vec![MigrationOp::DropUniqueConstraint {
table: QualifiedName::new("auth", "users"),
constraint_name: "users_email_unique".to_string(),
}];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_unique_constraint");
}
#[test]
fn allows_drop_unique_constraint_with_flag() {
let ops = vec![MigrationOp::DropUniqueConstraint {
table: QualifiedName::new("auth", "users"),
constraint_name: "users_email_unique".to_string(),
}];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
#[test]
fn blocks_drop_schema_without_flag() {
let ops = vec![MigrationOp::DropSchema("auth".to_string())];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_schema");
}
#[test]
fn allows_drop_schema_with_flag() {
let ops = vec![MigrationOp::DropSchema("auth".to_string())];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
#[test]
fn blocks_drop_extension_without_flag() {
let ops = vec![MigrationOp::DropExtension("uuid-ossp".to_string())];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_extension");
}
#[test]
fn allows_drop_extension_with_flag() {
let ops = vec![MigrationOp::DropExtension("uuid-ossp".to_string())];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
#[test]
fn blocks_drop_domain_without_flag() {
let ops = vec![MigrationOp::DropDomain("email_address".to_string())];
let options = LintOptions {
allow_destructive: false,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(has_errors(&results));
assert_eq!(results[0].rule, "deny_drop_domain");
}
#[test]
fn allows_drop_domain_with_flag() {
let ops = vec![MigrationOp::DropDomain("email_address".to_string())];
let options = LintOptions {
allow_destructive: true,
is_production: false,
};
let results = lint_migration_plan(&ops, &options);
assert!(!has_errors(&results));
}
}