Skip to content

Commit a3447c5

Browse files
authored
Merge pull request #251 from rust-lang/fix-migrations
Fix column migrations without `DEFAULT` clause and add a test for it
2 parents 4eb2bc0 + ddd3459 commit a3447c5

7 files changed

+193
-3
lines changed

Cargo.lock

Lines changed: 66 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ tracing-test = "0.2.4"
6363
regex = "1.10.4"
6464
parking_lot = "0.12.3"
6565
thread_local = "1.1.8"
66+
sqlparser = { version = "0.55", features = ["visitor"] }
6667

6768
[profile.release]
6869
debug = 1

docs/development.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ Having the database up and running, with the `DATABASE_URL` set, is required to
104104
Make sure to also run `cargo sqlx migrate run` to apply the migrations to the database.
105105

106106
### Updating the DB schema
107+
108+
> [!CAUTION]
109+
> When adding a new `NOT NULL` column, always specify the `DEFAULT` value that will be backfilled
110+
> during the migration! Otherwise, the migration might break the deployed bors service.
111+
107112
1) Generate a new migration
108113
```console
109114
$ cargo sqlx migrate add <new-migration>
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
-- Add up migration script here
2-
ALTER TABLE pull_request ADD COLUMN base_branch TEXT NOT NULL;
2+
ALTER TABLE pull_request
3+
ADD COLUMN base_branch TEXT NOT NULL DEFAULT 'master';
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
-- Add up migration script here
2-
ALTER TABLE pull_request ADD COLUMN status TEXT NOT NULL;
2+
ALTER TABLE pull_request
3+
ADD COLUMN status TEXT NOT NULL DEFAULT 'open';

tests/migrations.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use itertools::Itertools;
2+
use sqlparser::ast::{
3+
AlterColumnOperation, AlterTableOperation, ColumnOption, Ident, ObjectName, Statement, Visit,
4+
Visitor,
5+
};
6+
use sqlparser::dialect::PostgreSqlDialect;
7+
use sqlparser::parser::Parser;
8+
use std::collections::{BTreeSet, HashSet};
9+
use std::ffi::OsStr;
10+
use std::ops::ControlFlow;
11+
use std::path::PathBuf;
12+
13+
struct CheckNotNullWithoutDefault {
14+
error: Option<String>,
15+
columns_set_to_not_null: HashSet<(ObjectName, Ident)>,
16+
columns_set_default_value: HashSet<(ObjectName, Ident)>,
17+
}
18+
19+
impl Visitor for CheckNotNullWithoutDefault {
20+
type Break = ();
21+
22+
fn pre_visit_statement(&mut self, statement: &Statement) -> ControlFlow<Self::Break> {
23+
if let Statement::AlterTable {
24+
operations, name, ..
25+
} = statement
26+
{
27+
for op in operations {
28+
match op {
29+
AlterTableOperation::AddColumn { column_def, .. } => {
30+
let has_not_null = column_def
31+
.options
32+
.iter()
33+
.any(|option| option.option == ColumnOption::NotNull);
34+
let has_default = column_def
35+
.options
36+
.iter()
37+
.any(|option| matches!(option.option, ColumnOption::Default(_)));
38+
if has_not_null && !has_default {
39+
self.error = Some(format!(
40+
"Column `{name}.{}` is NOT NULL, but no DEFAULT value was configured!",
41+
column_def.name
42+
));
43+
return ControlFlow::Break(());
44+
}
45+
}
46+
AlterTableOperation::AlterColumn { column_name, op } => match op {
47+
AlterColumnOperation::SetNotNull => {
48+
self.columns_set_to_not_null
49+
.insert((name.clone(), column_name.clone()));
50+
}
51+
AlterColumnOperation::SetDefault { .. } => {
52+
self.columns_set_default_value
53+
.insert((name.clone(), column_name.clone()));
54+
}
55+
_ => {}
56+
},
57+
_ => {}
58+
}
59+
}
60+
}
61+
ControlFlow::Continue(())
62+
}
63+
}
64+
65+
impl CheckNotNullWithoutDefault {
66+
fn compute_error(self) -> Option<String> {
67+
if let Some(error) = self.error {
68+
return Some(error);
69+
}
70+
71+
let missing_default = self
72+
.columns_set_to_not_null
73+
.difference(&self.columns_set_default_value)
74+
.collect::<BTreeSet<_>>();
75+
if !missing_default.is_empty() {
76+
return Some(format!(
77+
"Column(s) {} were modified to NOT NULL, but no DEFAULT value was set for them",
78+
missing_default.iter().map(|v| format!("{v:?}")).join(",")
79+
));
80+
}
81+
82+
None
83+
}
84+
}
85+
86+
/// Check that there is no migration that would add a NOT NULL column (or make an existing column
87+
/// NOT NULL) without also providing a DEFAULT value.
88+
#[test]
89+
fn check_non_null_column_without_default() {
90+
let root = env!("CARGO_MANIFEST_DIR");
91+
let migrations = PathBuf::from(root).join("migrations");
92+
for file in std::fs::read_dir(migrations).unwrap() {
93+
let file = file.unwrap();
94+
if file.path().extension() == Some(OsStr::new("sql")) {
95+
let contents =
96+
std::fs::read_to_string(&file.path()).expect("cannot read migration file");
97+
98+
let ast = Parser::parse_sql(&PostgreSqlDialect {}, &contents).expect(&format!(
99+
"Cannot parse migration {} as SQLL",
100+
file.path().display()
101+
));
102+
let mut visitor = CheckNotNullWithoutDefault {
103+
error: None,
104+
columns_set_to_not_null: Default::default(),
105+
columns_set_default_value: Default::default(),
106+
};
107+
ast.visit(&mut visitor);
108+
109+
if let Some(error) = visitor.compute_error() {
110+
panic!(
111+
"Migration {} contains error: {error}",
112+
file.path().display()
113+
);
114+
}
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)