use std::collections::HashSet;
use std::fmt;
use tokio_postgres::Transaction;
use crate::desired_state::Checksum;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Migration {
pub version: i32,
pub name: &'static str,
pub sql: &'static str,
}
impl Migration {
pub fn checksum(&self) -> Checksum {
Checksum::of(self.sql.as_bytes())
}
pub fn relations(&self) -> Vec<String> {
let mut relations = Vec::new();
for statement in statements(self.sql) {
for expectation in expectations(statement).unwrap_or_default() {
if let Evidence::Table(name) = expectation.what
&& expectation.present
&& !relations.contains(&name)
{
relations.push(name);
}
}
}
relations
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Expectation {
what: Evidence,
present: bool,
proof: bool,
}
fn skipped(bytes: &[u8], at: usize) -> Option<Region> {
let after = |from: usize, needle: &[u8]| {
(from..=bytes.len().saturating_sub(needle.len()))
.find(|index| &bytes[*index..index + needle.len()] == needle)
.map_or(
Region {
end: bytes.len(),
certain: false,
},
|index| Region {
end: index + needle.len(),
certain: true,
},
)
};
match bytes[at] {
b'-' if bytes.get(at + 1) == Some(&b'-') => Some(Region {
certain: true,
..after(at + 2, b"\n")
}),
b'/' if bytes.get(at + 1) == Some(&b'*') => {
let (mut depth, mut index) = (1usize, at + 2);
while index < bytes.len() {
if bytes[index..].starts_with(b"/*") {
depth += 1;
index += 2;
} else if bytes[index..].starts_with(b"*/") {
depth -= 1;
index += 2;
if depth == 0 {
return Some(Region {
end: index,
certain: true,
});
}
} else {
index += 1;
}
}
Some(Region {
end: bytes.len(),
certain: false,
})
}
b'\'' => {
let literal = after(at + 1, b"'");
Some(Region {
certain: literal.certain && !bytes[at..literal.end].contains(&b'\\'),
..literal
})
}
b'$' => {
let tag = bytes[at + 1..]
.iter()
.position(|byte| !byte.is_ascii_alphanumeric() && *byte != b'_')
.filter(|end| bytes.get(at + 1 + end) == Some(&b'$'))?;
let delimiter = &bytes[at..=at + 1 + tag];
Some(after(at + delimiter.len(), delimiter))
}
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Region {
end: usize,
certain: bool,
}
fn lexed(sql: &str) -> bool {
let bytes = sql.as_bytes();
let mut index = 0;
while index < bytes.len() {
match skipped(bytes, index) {
Some(region) if !region.certain => return false,
Some(region) => index = region.end,
None => index += 1,
}
}
true
}
fn statements(sql: &str) -> Vec<&str> {
let mut statements = Vec::new();
let mut start = 0;
let mut index = 0;
let bytes = sql.as_bytes();
while index < bytes.len() {
if let Some(region) = skipped(bytes, index) {
index = region.end;
continue;
}
if bytes[index] == b';' {
let statement = sql[start..index].trim();
if !words(statement).is_empty() {
statements.push(statement);
}
start = index + 1;
}
index += 1;
}
let tail = sql[start..].trim();
if !words(tail).is_empty() {
statements.push(tail);
}
statements
}
fn words(statement: &str) -> Vec<&str> {
let mut words = Vec::new();
let mut start: Option<usize> = None;
let mut index = 0;
let bytes = statement.as_bytes();
while index < bytes.len() {
if let Some(region) = skipped(bytes, index) {
if let Some(from) = start.take() {
words.push(&statement[from..index]);
}
index = region.end;
continue;
}
if bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_' {
start = start.or(Some(index));
} else if let Some(from) = start.take() {
words.push(&statement[from..index]);
}
index += 1;
}
if let Some(from) = start {
words.push(&statement[from..]);
}
words
}
fn past(words: &[&str], mut at: usize) -> usize {
for skipped in ["CONCURRENTLY", "ONLY", "IF", "NOT", "EXISTS"] {
if words
.get(at)
.is_some_and(|word| word.eq_ignore_ascii_case(skipped))
{
at += 1;
}
}
at
}
fn named(statement: &str, words: &[&str], at: usize) -> Option<String> {
let at = past(words, at);
let name = *words.get(at)?;
if name.eq_ignore_ascii_case("ON") {
return None;
}
let from = offset(statement, name);
let bytes = statement.as_bytes();
let before = from.checked_sub(1).map(|at| bytes[at]);
if before == Some(b'.') || bytes.get(from + name.len()) == Some(&b'.') {
return None;
}
Some(name.to_owned())
}
fn offset(text: &str, word: &str) -> usize {
word.as_ptr() as usize - text.as_ptr() as usize
}
fn split(text: &str, from: usize, to: usize) -> Vec<&str> {
let bytes = text.as_bytes();
let (mut depth, mut start, mut index) = (0usize, from, from);
let mut parts = Vec::new();
while index < to {
if let Some(region) = skipped(bytes, index) {
index = region.end;
continue;
}
match bytes[index] {
b'(' => depth += 1,
b')' => depth = depth.saturating_sub(1),
b',' if depth == 0 => {
parts.push(text[start..index].trim());
start = index + 1;
}
_ => {}
}
index += 1;
}
parts.push(text[start..to].trim());
parts
}
fn expectations(statement: &str) -> Option<Vec<Expectation>> {
let words = words(statement);
let keyword = |position: usize, expected: &str| {
words
.get(position)
.is_some_and(|word| word.eq_ignore_ascii_case(expected))
};
let present = |what: Evidence| {
Some(vec![Expectation {
what,
present: true,
proof: true,
}])
};
if keyword(0, "CREATE") && keyword(1, "TABLE") {
return present(Evidence::Table(named(statement, &words, 2)?));
}
if keyword(0, "CREATE") && keyword(1, "INDEX") {
return present(Evidence::Index(named(statement, &words, 2)?));
}
if keyword(0, "CREATE") && keyword(1, "UNIQUE") && keyword(2, "INDEX") {
return present(Evidence::Index(named(statement, &words, 3)?));
}
if keyword(0, "DROP") && keyword(1, "INDEX") {
return Some(vec![Expectation {
what: Evidence::Index(named(statement, &words, 2)?),
present: false,
proof: true,
}]);
}
if keyword(0, "INSERT") && keyword(1, "INTO") {
let idempotent = words.windows(2).any(|pair| {
pair[0].eq_ignore_ascii_case("DO") && pair[1].eq_ignore_ascii_case("NOTHING")
});
return named(statement, &words, 2)
.filter(|_| idempotent)
.and_then(|table| present(Evidence::Seed(table)));
}
if keyword(1, "POLICY") && (keyword(0, "CREATE") || keyword(0, "DROP")) {
let at = past(&words, 2);
let policy = named(statement, &words, at)?;
if !words
.get(at + 1)
.is_some_and(|word| word.eq_ignore_ascii_case("ON"))
{
return None;
}
let table = named(statement, &words, at + 2)?;
return Some(vec![Expectation {
what: Evidence::Policy(table, policy),
present: keyword(0, "CREATE"),
proof: true,
}]);
}
if keyword(0, "ALTER") && keyword(1, "TABLE") {
let at = past(&words, 2);
let table = named(statement, &words, at)?;
let from = offset(statement, words[at]) + words[at].len();
return split(statement, from, statement.len())
.into_iter()
.map(|clause| altered(&table, clause))
.collect();
}
if keyword(0, "DO") && words.len() == 1 {
return unrolled(statement);
}
None
}
fn altered(table: &str, clause: &str) -> Option<Expectation> {
let words = words(clause);
let keyword = |position: usize, expected: &str| {
words
.get(position)
.is_some_and(|word| word.eq_ignore_ascii_case(expected))
};
let phrase = |expected: &[&str]| {
words.len() == expected.len()
&& words
.iter()
.zip(expected)
.all(|(word, expected)| word.eq_ignore_ascii_case(expected))
};
let flag = |what: Evidence, present: bool| {
Some(Expectation {
what,
present,
proof: true,
})
};
if phrase(&["ENABLE", "ROW", "LEVEL", "SECURITY"]) {
return flag(Evidence::Guarded(table.to_owned()), true);
}
if phrase(&["DISABLE", "ROW", "LEVEL", "SECURITY"]) {
return flag(Evidence::Guarded(table.to_owned()), false);
}
if phrase(&["FORCE", "ROW", "LEVEL", "SECURITY"]) {
return flag(Evidence::Forced(table.to_owned()), true);
}
if phrase(&["NO", "FORCE", "ROW", "LEVEL", "SECURITY"]) {
return flag(Evidence::Forced(table.to_owned()), false);
}
if keyword(1, "COLUMN") && (keyword(0, "ADD") || keyword(0, "DROP")) {
let column = named(clause, &words, 2)?;
return flag(
Evidence::Column(table.to_owned(), column),
keyword(0, "ADD"),
);
}
if keyword(1, "CONSTRAINT") && (keyword(0, "ADD") || keyword(0, "DROP")) {
let constraint = named(clause, &words, 2)?;
return flag(
Evidence::Constraint(table.to_owned(), constraint),
keyword(0, "ADD"),
);
}
None
}
fn unrolled(statement: &str) -> Option<Vec<Expectation>> {
let body = quoted(statement)?;
if !lexed(body) {
return None;
}
interpreted(&statements(body))
}
fn interpreted(chunks: &[&str]) -> Option<Vec<Expectation>> {
let mut expectations = Vec::new();
let mut index = 0;
while index < chunks.len() {
let chunk = after(chunks[index], "BEGIN").unwrap_or(chunks[index]);
let words = words(chunk);
let word = |position: usize, expected: &str| {
words
.get(position)
.is_some_and(|word| word.eq_ignore_ascii_case(expected))
};
if words.is_empty()
|| word(0, "DECLARE")
|| (word(0, "END") && (words.len() == 1 || word(1, "IF") || word(1, "LOOP")))
{
index += 1;
continue;
}
if word(0, "IF") {
let (guard, body, next) = guarded(chunk, chunks, index)?;
for statement in body {
for expectation in self::expectations(statement)? {
if !expectation.present || !stated(&expectation.what, &guard) {
return None;
}
expectations.push(expectation);
}
}
index = next;
continue;
}
if let Some(header) = after(chunk, "FOREACH") {
let (over, body, next) = looped(header, chunks, index)?;
let (variable, names) = listed(&over)?;
for name in &names {
for statement in &body {
expectations
.extend(self::expectations(&rendered(statement, &variable, name)?)?);
}
}
index = next;
continue;
}
if let Some(header) = after(chunk, "FOR") {
let (over, body, next) = looped(header, chunks, index)?;
expectations.push(cleared(&over, &body)?);
index = next;
continue;
}
expectations.extend(self::expectations(chunk)?);
index += 1;
}
Some(expectations)
}
fn after<'a>(text: &'a str, keyword: &str) -> Option<&'a str> {
let words = words(text);
let first = words.first()?;
if !first.eq_ignore_ascii_case(keyword) {
return None;
}
Some(text[offset(text, first) + first.len()..].trim())
}
fn guarded<'a>(
opening: &'a str,
chunks: &[&'a str],
index: usize,
) -> Option<(String, Vec<&'a str>, usize)> {
let (head, first) = divided(opening, "THEN")?;
let condition = words(&head);
if !(condition.len() > 3
&& condition[0].eq_ignore_ascii_case("IF")
&& condition[1].eq_ignore_ascii_case("NOT")
&& condition[2].eq_ignore_ascii_case("EXISTS")
&& condition[3].eq_ignore_ascii_case("SELECT"))
{
return None;
}
let (body, next) = bodied(first, chunks, index, &["END", "IF"])?;
Some((head, body, next))
}
fn looped<'a>(
opening: &'a str,
chunks: &[&'a str],
index: usize,
) -> Option<(String, Vec<&'a str>, usize)> {
let (over, first) = divided(opening, "LOOP")?;
let (body, next) = bodied(first, chunks, index, &["END", "LOOP"])?;
Some((over, body, next))
}
fn divided<'a>(chunk: &'a str, keyword: &str) -> Option<(String, &'a str)> {
let words = words(chunk);
let at = words.iter().position(|word| {
word.eq_ignore_ascii_case(keyword)
&& depth(chunk, offset(chunk, word)) == 0
})?;
let from = offset(chunk, words[at]);
Some((
chunk[..from].trim().to_owned(),
chunk[from + words[at].len()..].trim(),
))
}
fn depth(text: &str, at: usize) -> usize {
let bytes = text.as_bytes();
let (mut depth, mut index) = (0usize, 0);
while index < at {
if let Some(region) = skipped(bytes, index) {
index = region.end;
continue;
}
match bytes[index] {
b'(' => depth += 1,
b')' => depth = depth.saturating_sub(1),
_ => {}
}
index += 1;
}
depth
}
fn bodied<'a>(
first: &'a str,
chunks: &[&'a str],
index: usize,
closing: &[&str],
) -> Option<(Vec<&'a str>, usize)> {
let mut body = Vec::new();
if !words(first).is_empty() {
body.push(first);
}
for (at, chunk) in chunks.iter().enumerate().skip(index + 1) {
let words = words(chunk);
if words.len() == closing.len()
&& words
.iter()
.zip(closing)
.all(|(word, expected)| word.eq_ignore_ascii_case(expected))
{
return Some((body, at + 1));
}
if words.first().is_some_and(|word| {
[
"IF", "FOR", "FOREACH", "WHILE", "LOOP", "CASE", "BEGIN", "END",
]
.iter()
.any(|structure| word.eq_ignore_ascii_case(structure))
}) {
return None;
}
body.push(chunk);
}
None
}
fn listed(header: &str) -> Option<(String, Vec<String>)> {
let words = words(header);
let [variable, over @ ..] = words.as_slice() else {
return None;
};
if !over
.iter()
.zip(["IN", "ARRAY", "ARRAY"])
.all(|(word, expected)| word.eq_ignore_ascii_case(expected))
|| over.len() != 3
{
return None;
}
let names = literals(header);
if names.is_empty() {
return None;
}
Some(((*variable).to_owned(), names))
}
fn cleared(header: &str, body: &[&str]) -> Option<Expectation> {
let (variable, query) = divided(header, "IN")?;
if words(&variable).len() != 1 {
return None;
}
for statement in body {
let (dropped, arguments) = templated(statement)?;
let words = words(&dropped);
let drops = words.windows(2).any(|pair| {
pair[0].eq_ignore_ascii_case("DROP") && pair[1].eq_ignore_ascii_case("CONSTRAINT")
});
if !drops
|| !arguments
.iter()
.all(|argument| argument.starts_with(&format!("{}.", variable.trim())))
{
return None;
}
}
Some(Expectation {
what: Evidence::Stale {
table: literals(query).first()?.clone(),
query: catalogued(query)?,
except: Vec::new(),
},
present: false,
proof: false,
})
}
fn catalogued(query: &str) -> Option<String> {
let words = words(query);
if !words.first()?.eq_ignore_ascii_case("SELECT") {
return None;
}
if !words
.iter()
.any(|word| word.eq_ignore_ascii_case("pg_constraint"))
{
return None;
}
let (selected, _) = divided(query, "FROM")?;
if !self::words(&selected)
.iter()
.any(|word| word.eq_ignore_ascii_case("conname"))
{
return None;
}
if words.iter().any(|word| {
[
"INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "CREATE", "GRANT", "REVOKE", "TRUNCATE",
"COPY", "CALL", "DO", "SET", "LOCK", "NEXTVAL", "PG_SLEEP",
]
.iter()
.any(|forbidden| word.eq_ignore_ascii_case(forbidden))
}) {
return None;
}
Some(query.to_owned())
}
fn templated(statement: &str) -> Option<(String, Vec<String>)> {
let words = words(statement);
if !(words.first()?.eq_ignore_ascii_case("EXECUTE")
&& words.get(1)?.eq_ignore_ascii_case("format"))
{
return None;
}
let open = statement.find('(')?;
let close = statement.rfind(')')?;
let arguments = split(statement, open + 1, close);
let (template, arguments) = arguments.split_first()?;
let quoted = literals(template);
let [template] = quoted.as_slice() else {
return None;
};
Some((
template.replace("%I", " ").replace("%s", " "),
arguments
.iter()
.map(|argument| argument.trim().to_owned())
.collect(),
))
}
fn stated(what: &Evidence, condition: &str) -> bool {
let literals = literals(condition);
let names = match what {
Evidence::Table(name) | Evidence::Index(name) | Evidence::Seed(name) => vec![name],
Evidence::Column(table, column) => vec![table, column],
Evidence::Constraint(table, constraint) => vec![table, constraint],
Evidence::Policy(table, policy) => vec![table, policy],
Evidence::Guarded(table) | Evidence::Forced(table) => vec![table],
Evidence::Stale { .. } => return false,
};
names
.into_iter()
.all(|name| literals.iter().any(|literal| literal == name))
}
fn quoted(statement: &str) -> Option<&str> {
let bytes = statement.as_bytes();
let mut index = 0;
while index < bytes.len() {
let region = skipped(bytes, index);
if bytes[index] == b'$'
&& let Some(region) = region
{
if !region.certain {
return None;
}
let tag = bytes[index + 1..].iter().position(|byte| *byte == b'$')? + 2;
return statement.get(index + tag..region.end - tag);
}
index = region.map_or(index + 1, |region| region.end);
}
None
}
fn literals(text: &str) -> Vec<String> {
let bytes = text.as_bytes();
let mut literals = Vec::new();
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'\'' {
index += 1;
continue;
}
let mut value = String::new();
index += 1;
while index < bytes.len() {
if bytes[index] == b'\'' {
if bytes.get(index + 1) == Some(&b'\'') {
value.push('\'');
index += 2;
continue;
}
index += 1;
break;
}
value.push(bytes[index] as char);
index += 1;
}
literals.push(value);
}
literals
}
fn rendered(statement: &str, variable: &str, name: &str) -> Option<String> {
let words = words(statement);
if !(words.first()?.eq_ignore_ascii_case("EXECUTE")
&& words.get(1)?.eq_ignore_ascii_case("format"))
{
return None;
}
let open = statement.find('(')?;
let close = statement.rfind(')')?;
let arguments = split(statement, open + 1, close);
let (template, arguments) = arguments.split_first()?;
let template = match literals(template).as_slice() {
[only] if template.starts_with('\'') && template.ends_with('\'') => only.clone(),
_ => return None,
};
let mut values = Vec::new();
for argument in arguments {
let value = match self::words(argument).as_slice() {
[only] if *only == variable && argument.trim() == variable => name.to_owned(),
[only] if *only == variable => match literals(argument).as_slice() {
[suffix] if argument.contains("||") => format!("{name}{suffix}"),
_ => return None,
},
[] => match literals(argument).as_slice() {
[only] => only.clone(),
_ => return None,
},
_ => return None,
};
values.push(value);
}
let mut rendered = String::new();
let mut values = values.iter();
let mut characters = template.chars();
while let Some(character) = characters.next() {
if character != '%' {
rendered.push(character);
continue;
}
match characters.next()? {
'%' => rendered.push('%'),
'I' => {
let value = values.next()?;
if value.is_empty()
|| !value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
})
{
return None;
}
rendered.push_str(value);
}
's' => rendered.push_str(values.next()?),
_ => return None,
}
}
values.next().map_or(Some(rendered), |_| None)
}
pub const MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "control_plane_0001_initial",
sql: include_str!("../../../sql/control_plane_0001_initial.sql"),
},
Migration {
version: 2,
name: "control_plane_0002_tenancy_access",
sql: include_str!("../../../sql/control_plane_0002_tenancy_access.sql"),
},
Migration {
version: 3,
name: "control_plane_0003_tenancy_constraints",
sql: include_str!("../../../sql/control_plane_0003_tenancy_constraints.sql"),
},
Migration {
version: 4,
name: "control_plane_0004_journal_ownership",
sql: include_str!("../../../sql/control_plane_0004_journal_ownership.sql"),
},
];
pub fn required_version() -> i32 {
MIGRATIONS
.last()
.expect("at least one migration ships")
.version
}
pub const MINIMUM_SERVER_VERSION_NUM: i32 = 140_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaStatus {
Absent,
Unrecorded,
Current {
version: i32,
},
Behind {
applied: i32,
required: i32,
},
Ahead {
applied: i32,
required: i32,
},
Drifted {
version: i32,
expected: Checksum,
found: Checksum,
},
Incomplete {
applied: i32,
missing: Vec<i32>,
},
Renamed {
version: i32,
expected: &'static str,
found: String,
},
Malformed {
message: String,
},
}
impl SchemaStatus {
pub fn is_current(&self) -> bool {
matches!(self, Self::Current { .. })
}
pub fn is_migratable(&self) -> bool {
matches!(self, Self::Absent | Self::Behind { .. })
}
}
impl fmt::Display for SchemaStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Absent => write!(
f,
"the control-plane schema is not present; run `axond migrate apply` (or apply \
ops/postgres/control_plane_0001_initial.sql)"
),
Self::Unrecorded => write!(
f,
"`{MIGRATION_TABLE}` exists but records no migrations, so this build cannot tell \
whether the schema it describes was ever applied and will not migrate from zero \
over objects that may already exist; if the DDL was applied out of band, run \
`axond migrate adopt` to record the baseline the database's own objects account \
for, and if nothing was applied, drop the empty `{MIGRATION_TABLE}` table and \
run `axond migrate apply`"
),
Self::Current { version } => write!(f, "control-plane schema v{version} is current"),
Self::Behind { applied, required } => write!(
f,
"control-plane schema is v{applied}, but this build requires v{required}; run \
`axond migrate apply` before starting replicas"
),
Self::Ahead { applied, required } => write!(
f,
"control-plane schema is v{applied}, which is newer than the v{required} this \
build knows; a newer gateway owns this database"
),
Self::Drifted {
version,
expected,
found,
} => write!(
f,
"control-plane migration v{version} was applied as {found}, but this build ships \
{expected}; an applied migration was edited in place"
),
Self::Incomplete { applied, missing } => write!(
f,
"control-plane schema records v{applied} but is missing {}; the applied versions \
are not a complete history, so this build cannot tell what the database contains",
missing
.iter()
.map(|version| format!("v{version}"))
.collect::<Vec<_>>()
.join(", ")
),
Self::Renamed {
version,
expected,
found,
} => write!(
f,
"control-plane migration v{version} is recorded as `{found}`, but this build ships \
v{version} as `{expected}`; a migration was renumbered or renamed rather than \
added"
),
Self::Malformed { message } => write!(
f,
"the control-plane migration ledger is not the one this build writes: {message}"
),
}
}
}
pub(crate) const MIGRATION_TABLE: &str = "axond_cp_schema_migration";
pub(super) async fn status(
transaction: &Transaction<'_>,
) -> Result<SchemaStatus, tokio_postgres::Error> {
let present: Option<String> = transaction
.query_one("SELECT to_regclass($1)::text", &[&MIGRATION_TABLE])
.await?
.get(0);
if present.is_none() {
return Ok(SchemaStatus::Absent);
}
let rows = match transaction
.query(
&format!("SELECT version, name, checksum FROM {MIGRATION_TABLE} ORDER BY version"),
&[],
)
.await
{
Ok(rows) => rows,
Err(error) if is_schema_disagreement(&error) => {
return Ok(SchemaStatus::Malformed {
message: format!(
"reading `{MIGRATION_TABLE}` as (version, name, checksum) failed: {error}"
),
});
}
Err(error) => return Err(error),
};
let mut recorded = Vec::with_capacity(rows.len());
for row in &rows {
let decoded = row
.try_get(0)
.and_then(|version| {
Ok(Recorded {
version,
name: row.try_get(1)?,
checksum: row.try_get(2)?,
})
})
.map_err(|error| format!("`{MIGRATION_TABLE}` holds a row this build cannot read as (version integer, name text, checksum text): {error}"));
match decoded {
Ok(row) => recorded.push(row),
Err(message) => return Ok(SchemaStatus::Malformed { message }),
}
}
Ok(classify(&recorded))
}
fn is_schema_disagreement(error: &tokio_postgres::Error) -> bool {
error
.code()
.is_some_and(|code| code.code().starts_with("42"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Recorded {
pub version: i32,
pub name: String,
pub checksum: String,
}
fn classify(recorded: &[Recorded]) -> SchemaStatus {
let required = required_version();
if let Some(row) = recorded.iter().find(|row| row.version < 1) {
return SchemaStatus::Malformed {
message: format!(
"v{} is recorded, but migration versions start at 1",
row.version
),
};
}
let mut versions: Vec<i32> = recorded.iter().map(|row| row.version).collect();
versions.sort_unstable();
versions.dedup();
if versions.len() != recorded.len() {
return SchemaStatus::Malformed {
message: "a version is recorded more than once, so the ledger's primary key is not \
the one this build writes"
.to_owned(),
};
}
let Some(applied) = versions.last().copied() else {
return SchemaStatus::Unrecorded;
};
for row in recorded {
let Some(migration) = MIGRATIONS.iter().find(|m| m.version == row.version) else {
return SchemaStatus::Ahead {
applied: row.version,
required,
};
};
if row.name != migration.name {
return SchemaStatus::Renamed {
version: row.version,
expected: migration.name,
found: row.name.clone(),
};
}
let expected = migration.checksum();
if row.checksum != expected.to_string() {
return SchemaStatus::Drifted {
version: row.version,
expected,
found: Checksum::parse(&row.checksum).unwrap_or(expected),
};
}
}
let missing: Vec<i32> = (1..=applied)
.filter(|version| !versions.contains(version))
.collect();
if !missing.is_empty() {
return SchemaStatus::Incomplete { applied, missing };
}
match applied.cmp(&required) {
std::cmp::Ordering::Equal => SchemaStatus::Current { version: applied },
std::cmp::Ordering::Less => SchemaStatus::Behind { applied, required },
std::cmp::Ordering::Greater => SchemaStatus::Ahead { applied, required },
}
}
pub fn pending(from: &SchemaStatus) -> Vec<i32> {
let applied = match from {
SchemaStatus::Absent => 0,
SchemaStatus::Behind { applied, .. } => *applied,
SchemaStatus::Current { .. }
| SchemaStatus::Unrecorded
| SchemaStatus::Ahead { .. }
| SchemaStatus::Drifted { .. }
| SchemaStatus::Incomplete { .. }
| SchemaStatus::Renamed { .. }
| SchemaStatus::Malformed { .. } => return Vec::new(),
};
MIGRATIONS
.iter()
.filter(|migration| migration.version > applied)
.map(|migration| migration.version)
.collect()
}
pub(super) async fn migrate(
transaction: &Transaction<'_>,
from: &SchemaStatus,
) -> Result<(), tokio_postgres::Error> {
let applied = match from {
SchemaStatus::Behind { applied, .. } => *applied,
_ => 0,
};
for migration in MIGRATIONS.iter().filter(|m| m.version > applied) {
transaction.batch_execute(migration.sql).await?;
transaction
.execute(
&format!(
"INSERT INTO {MIGRATION_TABLE} (version, name, checksum) VALUES ($1, $2, $3) \
ON CONFLICT (version) DO NOTHING"
),
&[
&migration.version,
&migration.name,
&migration.checksum().to_string(),
],
)
.await?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Baseline {
Applied { versions: Vec<i32> },
Nothing,
Inconsistent { message: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Evidence {
Table(String),
Index(String),
Seed(String),
Column(String, String),
Constraint(String, String),
Guarded(String),
Forced(String),
Policy(String, String),
Stale {
table: String,
query: String,
except: Vec<String>,
},
}
fn evidence(migration: &Migration) -> Option<Vec<Expectation>> {
if !lexed(migration.sql) {
return None;
}
let mut evidence: Vec<Expectation> = Vec::new();
for statement in statements(migration.sql) {
for expectation in expectations(statement)? {
if expectation.what == Evidence::Table(MIGRATION_TABLE.to_owned()) {
continue;
}
match evidence
.iter_mut()
.find(|prior| prior.what == expectation.what)
{
Some(prior) if !matches!(expectation.what, Evidence::Seed(_)) => {
prior.proof = prior.proof && prior.present == expectation.present;
prior.present = expectation.present;
}
_ => evidence.push(expectation),
}
}
}
let declared: Vec<String> = evidence
.iter()
.filter_map(|item| match &item.what {
Evidence::Constraint(_, constraint) if item.present => Some(constraint.clone()),
_ => None,
})
.collect();
for item in &mut evidence {
if let Evidence::Stale { except, .. } = &mut item.what {
*except = declared.clone();
}
}
Some(evidence)
}
fn described(expectation: &Expectation) -> String {
let thing = named_thing(&expectation.what);
match (&expectation.what, expectation.present) {
(Evidence::Seed(_), true) => format!("{thing} has no seeded row"),
(Evidence::Seed(_), false) => format!("{thing} still has its seeded row"),
(Evidence::Guarded(_) | Evidence::Forced(_), true) => format!("{thing} is not enabled"),
(Evidence::Guarded(_) | Evidence::Forced(_), false) => format!("{thing} is still enabled"),
(Evidence::Stale { .. }, _) => format!("{thing} is still there"),
(_, true) => format!("{thing} is not present"),
(_, false) => format!("{thing} is still present"),
}
}
fn named_thing(what: &Evidence) -> String {
match what {
Evidence::Table(name) | Evidence::Index(name) | Evidence::Seed(name) => format!("`{name}`"),
Evidence::Column(table, column) => format!("`{table}`'s `{column}` column"),
Evidence::Constraint(table, constraint) => {
format!("`{table}`'s `{constraint}` constraint")
}
Evidence::Guarded(table) => format!("row level security on `{table}`"),
Evidence::Forced(table) => format!("forced row level security on `{table}`"),
Evidence::Policy(table, policy) => format!("`{table}`'s `{policy}` policy"),
Evidence::Stale { table, .. } => {
format!("a definition on `{table}` that this migration replaces")
}
}
}
pub(super) async fn baseline(
transaction: &Transaction<'_>,
) -> Result<Baseline, tokio_postgres::Error> {
let mut confirmed: HashSet<Evidence> = HashSet::new();
for item in MIGRATIONS
.iter()
.filter_map(evidence)
.flatten()
.map(|expectation| expectation.what)
{
let found: bool = match &item {
Evidence::Table(name) | Evidence::Index(name) => transaction
.query_one(
"SELECT EXISTS (\
SELECT 1 FROM pg_catalog.pg_class class \
JOIN pg_catalog.pg_namespace namespace \
ON namespace.oid = class.relnamespace \
WHERE class.relname = $1 \
AND class.relkind IN ('r', 'p', 'i', 'I') \
AND namespace.nspname = current_schema())",
&[&name],
)
.await?
.get(0),
Evidence::Seed(name) => {
if !confirmed.contains(&Evidence::Table(name.clone())) {
false
} else {
transaction
.query_one(&format!("SELECT EXISTS (SELECT 1 FROM {name})"), &[])
.await?
.get(0)
}
}
Evidence::Column(table, column) => transaction
.query_one(
"SELECT EXISTS (\
SELECT 1 FROM pg_catalog.pg_attribute attribute \
JOIN pg_catalog.pg_class class \
ON class.oid = attribute.attrelid \
JOIN pg_catalog.pg_namespace namespace \
ON namespace.oid = class.relnamespace \
WHERE class.relname = $1 \
AND attribute.attname = $2 \
AND attribute.attnum > 0 \
AND NOT attribute.attisdropped \
AND namespace.nspname = current_schema())",
&[&table, &column],
)
.await?
.get(0),
Evidence::Constraint(table, constraint) => transaction
.query_one(
"SELECT EXISTS (\
SELECT 1 FROM pg_catalog.pg_constraint constraint_ \
JOIN pg_catalog.pg_class class \
ON class.oid = constraint_.conrelid \
JOIN pg_catalog.pg_namespace namespace \
ON namespace.oid = class.relnamespace \
WHERE class.relname = $1 \
AND constraint_.conname = $2 \
AND namespace.nspname = current_schema())",
&[&table, &constraint],
)
.await?
.get(0),
Evidence::Guarded(table) | Evidence::Forced(table) => transaction
.query_one(
&format!(
"SELECT EXISTS (\
SELECT 1 FROM pg_catalog.pg_class class \
JOIN pg_catalog.pg_namespace namespace \
ON namespace.oid = class.relnamespace \
WHERE class.relname = $1 \
AND class.{} \
AND namespace.nspname = current_schema())",
match item {
Evidence::Forced(_) => "relforcerowsecurity",
_ => "relrowsecurity",
}
),
&[&table],
)
.await?
.get(0),
Evidence::Policy(table, policy) => transaction
.query_one(
"SELECT EXISTS (\
SELECT 1 FROM pg_catalog.pg_policy policy \
JOIN pg_catalog.pg_class class \
ON class.oid = policy.polrelid \
JOIN pg_catalog.pg_namespace namespace \
ON namespace.oid = class.relnamespace \
WHERE class.relname = $1 \
AND policy.polname = $2 \
AND namespace.nspname = current_schema())",
&[&table, &policy],
)
.await?
.get(0),
Evidence::Stale { query, except, .. } => {
transaction
.batch_execute(
"SAVEPOINT stale_definitions; \
SELECT set_config('search_path', current_schema(), true)",
)
.await?;
let found = transaction
.query_one(
&format!(
"SELECT EXISTS (\
SELECT 1 FROM ({query}) stale \
WHERE stale.conname <> ALL ($1::text[]))"
),
&[except],
)
.await;
transaction
.batch_execute(
"ROLLBACK TO SAVEPOINT stale_definitions; \
RELEASE SAVEPOINT stale_definitions",
)
.await?;
match found {
Ok(row) => row.get(0),
Err(error)
if error.code()
== Some(&tokio_postgres::error::SqlState::UNDEFINED_TABLE) =>
{
false
}
Err(error) => return Err(error),
}
}
};
if found {
confirmed.insert(item);
}
}
Ok(reconcile(MIGRATIONS, &confirmed))
}
fn reconcile(migrations: &[Migration], confirmed: &HashSet<Evidence>) -> Baseline {
let mut declared: Vec<Vec<Expectation>> = Vec::new();
for migration in migrations {
let Some(items) =
evidence(migration).filter(|items| items.iter().any(|item| item.present && item.proof))
else {
return Baseline::Inconsistent {
message: format!(
"v{} `{}` contains a statement whose effect this database cannot be asked \
about, so whether it was applied is not something adoption can confirm — and \
recording a baseline below it would leave `axond migrate apply` to re-run it \
over a schema that may already have it. No baseline is adoptable while it \
ships unrecorded: state the history with `INSERT INTO {MIGRATION_TABLE} \
(version, name, checksum)` if you own the change that applied it, or drop the \
empty ledger and apply from zero if nothing was.",
migration.version, migration.name,
),
};
};
declared.push(items);
}
for (migration, items) in migrations.iter().zip(&declared) {
if let Some(Evidence::Seed(name)) = items
.iter()
.map(|item| &item.what)
.filter(|what| matches!(what, Evidence::Seed(_)))
.find(|what| {
declared
.iter()
.flatten()
.filter(|other| &other.what == *what)
.count()
> 1
})
{
return Baseline::Inconsistent {
message: format!(
"v{} `{}` seeds `{name}`, which the shipped history seeds more than once, so a \
row in it proves at most one of those inserts and not this one: whether this \
version ran is not something adoption can confirm. No baseline is adoptable \
while they all ship: state the history with `INSERT INTO {MIGRATION_TABLE} \
(version, name, checksum)` if you own the change that applied it, or drop the \
empty ledger and apply from zero if nothing was.",
migration.version, migration.name,
),
};
}
}
for (migration, items) in migrations.iter().zip(&declared) {
let shared = |what: &Evidence| {
declared
.iter()
.filter(|other| other.iter().any(|item| &item.what == what))
.count()
> 1
};
let mut proof = items.iter().filter(|item| item.present && item.proof);
if proof.clone().all(|item| shared(&item.what)) {
let Some(item) = proof.next() else {
unreachable!("a migration with no proof of its own was refused above");
};
return Baseline::Inconsistent {
message: format!(
"v{} `{}` acts on {}, which more than one shipped migration acts on, so what \
the database shows proves at most one of them and not this one: whether this \
version ran is not something adoption can confirm. No baseline is adoptable \
while they all ship: state the history with `INSERT INTO {MIGRATION_TABLE} \
(version, name, checksum)` if you own the change that applied it, or drop the \
empty ledger and apply from zero if nothing was.",
migration.version,
migration.name,
named_thing(&item.what),
),
};
}
}
for length in (1..=migrations.len()).rev() {
match fitted(&migrations[..length], &declared[..length], confirmed) {
Fit::Baseline => {
if let Some(skipped) = migrations[length..]
.iter()
.zip(&declared[length..])
.find(|(_, items)| {
let mut proof = items.iter().filter(|item| item.present && item.proof);
proof.clone().next().is_some()
&& proof.all(|item| confirmed.contains(&item.what))
})
.map(|(migration, _)| migration)
{
return Baseline::Inconsistent {
message: format!(
"v{} `{}` declares objects that are present while an earlier version's \
are not; this database is not a prefix of the shipped migration \
history, so no baseline describes it. State the history with `INSERT \
INTO {MIGRATION_TABLE} (version, name, checksum)` if you own the \
change that applied it.",
skipped.version, skipped.name,
),
};
}
return Baseline::Applied {
versions: migrations[..length]
.iter()
.map(|migration| migration.version)
.collect(),
};
}
Fit::Refused { message } => return Baseline::Inconsistent { message },
Fit::Shorter => {}
}
}
if confirmed.is_empty() {
return Baseline::Nothing;
}
let proven = |items: &Vec<Expectation>| {
items
.iter()
.any(|item| item.present && item.proof && confirmed.contains(&item.what))
};
let hole = migrations
.iter()
.zip(&declared)
.find(|(_, items)| !proven(items))
.map(|(migration, _)| migration.version);
let above = migrations
.iter()
.zip(&declared)
.filter(|(migration, items)| Some(migration.version) > hole && proven(items))
.map(|(migration, _)| (migration.version, migration.name))
.next_back();
Baseline::Inconsistent {
message: match (hole, above) {
(Some(hole), Some((version, name))) => format!(
"v{version} `{name}` declares objects that are present while v{hole} declares \
objects that are not; this database is not a prefix of the shipped migration \
history, so no baseline describes it"
),
_ => format!(
"objects the shipped migrations act on are present, but nothing in this schema \
shows which version put them there — no baseline describes it. State the history \
with `INSERT INTO {MIGRATION_TABLE} (version, name, checksum)` if you own the \
change that applied it."
),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Fit {
Baseline,
Shorter,
Refused { message: String },
}
fn fitted(
migrations: &[Migration],
declared: &[Vec<Expectation>],
confirmed: &HashSet<Evidence>,
) -> Fit {
let mut left: Vec<(usize, &Expectation)> = Vec::new();
for (index, items) in declared.iter().enumerate() {
for item in items {
match left.iter_mut().find(|(_, prior)| prior.what == item.what) {
Some(owner) => *owner = (index, item),
None => left.push((index, item)),
}
}
}
let replaced = |what: &Evidence| {
declared
.iter()
.filter(|items| items.iter().any(|item| &item.what == what))
.count()
> 1
};
for (migration, items) in migrations.iter().zip(declared) {
let proven = items.iter().any(|item| {
item.present && item.proof && !replaced(&item.what) && confirmed.contains(&item.what)
});
if !proven {
return Fit::Shorter;
}
let missing: Vec<String> = left
.iter()
.filter(|(owner, item)| {
migrations[*owner].version == migration.version
&& confirmed.contains(&item.what) != item.present
})
.map(|(_, item)| described(item))
.collect();
if !missing.is_empty() {
return Fit::Refused {
message: format!(
"v{} `{}` is only partly applied: {}, so this build cannot record it as \
applied and cannot apply it over what is there either. Finish or undo that \
migration by hand, then re-run.",
migration.version,
migration.name,
missing.join(", "),
),
};
}
}
Fit::Baseline
}
pub(super) async fn record_baseline(
transaction: &Transaction<'_>,
versions: &[i32],
) -> Result<(), tokio_postgres::Error> {
for migration in MIGRATIONS
.iter()
.filter(|migration| versions.contains(&migration.version))
{
transaction
.execute(
&format!(
"INSERT INTO {MIGRATION_TABLE} (version, name, checksum) VALUES ($1, $2, $3) \
ON CONFLICT (version) DO NOTHING"
),
&[
&migration.version,
&migration.name,
&migration.checksum().to_string(),
],
)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn recorded(version: i32) -> Recorded {
let migration = MIGRATIONS
.iter()
.find(|m| m.version == version)
.expect("shipped migration");
Recorded {
version,
name: migration.name.to_owned(),
checksum: migration.checksum().to_string(),
}
}
fn foreign(version: i32, name: &str, checksum: &str) -> Recorded {
Recorded {
version,
name: name.to_owned(),
checksum: checksum.to_owned(),
}
}
fn present(what: Evidence) -> Expectation {
Expectation {
what,
present: true,
proof: true,
}
}
fn replaced(what: Evidence) -> Expectation {
Expectation {
what,
present: true,
proof: false,
}
}
fn gone(what: Evidence) -> Expectation {
Expectation {
what,
present: false,
proof: true,
}
}
fn table(name: &str) -> Evidence {
Evidence::Table(name.to_owned())
}
fn index(name: &str) -> Evidence {
Evidence::Index(name.to_owned())
}
fn seed(name: &str) -> Evidence {
Evidence::Seed(name.to_owned())
}
fn column(table: &str, column: &str) -> Evidence {
Evidence::Column(table.to_owned(), column.to_owned())
}
fn constraint(table: &str, constraint: &str) -> Evidence {
Evidence::Constraint(table.to_owned(), constraint.to_owned())
}
fn policy(table: &str, policy: &str) -> Evidence {
Evidence::Policy(table.to_owned(), policy.to_owned())
}
#[test]
fn migrations_are_gapless_and_never_reordered() {
for (index, migration) in MIGRATIONS.iter().enumerate() {
assert_eq!(
migration.version,
i32::try_from(index + 1).expect("small"),
"migrations are numbered from 1 without gaps, so `applied` is a version count"
);
assert!(
migration.name.starts_with("control_plane_"),
"a migration's name is its shipped file's stem"
);
assert!(!migration.sql.trim().is_empty());
}
assert_eq!(required_version(), MIGRATIONS.len() as i32);
}
#[test]
fn an_unrecorded_or_partial_history_is_not_current_and_a_complete_one_is() {
assert_eq!(classify(&[]), SchemaStatus::Unrecorded);
let complete: Vec<_> = MIGRATIONS.iter().map(|m| recorded(m.version)).collect();
assert_eq!(
classify(&complete),
SchemaStatus::Current {
version: required_version()
}
);
assert!(classify(&complete).is_current());
}
#[test]
fn an_unknown_version_is_ahead_and_never_migratable() {
let status = classify(&[
recorded(1),
foreign(
99,
"control_plane_0099_future",
&Checksum::of(b"newer").to_string(),
),
]);
assert_eq!(
status,
SchemaStatus::Ahead {
applied: 99,
required: required_version()
}
);
assert!(!status.is_migratable());
assert!(status.to_string().contains("newer gateway"));
}
#[test]
fn an_edited_applied_migration_is_drift_rather_than_a_matching_version() {
let status = classify(&[foreign(
1,
MIGRATIONS[0].name,
&Checksum::of(b"edited in place").to_string(),
)]);
let SchemaStatus::Drifted {
version,
expected,
found,
} = status.clone()
else {
panic!("an edited migration must be reported as drift, got {status:?}");
};
assert_eq!(version, 1);
assert_eq!(expected, MIGRATIONS[0].checksum());
assert_eq!(found, Checksum::of(b"edited in place"));
assert!(!status.is_migratable());
assert!(!status.is_current());
}
#[test]
fn a_renamed_migration_is_reported_as_a_rename_even_when_its_text_matches() {
let mut row = recorded(1);
row.name = "control_plane_0001_initial_v2".to_owned();
let status = classify(&[row]);
assert_eq!(
status,
SchemaStatus::Renamed {
version: 1,
expected: MIGRATIONS[0].name,
found: "control_plane_0001_initial_v2".to_owned(),
}
);
assert!(
!status.is_migratable(),
"a version this build ships under another name is not a history it can extend"
);
assert!(status.to_string().contains("renumbered or renamed"));
}
#[test]
fn a_hole_in_the_applied_prefix_is_incomplete_rather_than_current_or_behind() {
let beyond = required_version() + 1;
let status = classify(&[foreign(
beyond,
"control_plane_9999_later",
&Checksum::of(b"later").to_string(),
)]);
assert_eq!(
status,
SchemaStatus::Ahead {
applied: beyond,
required: required_version()
},
);
let applied = required_version();
let missing: Vec<i32> = (1..applied).collect();
let status = SchemaStatus::Incomplete { applied, missing };
assert!(!status.is_migratable());
assert!(!status.is_current());
assert!(status.to_string().contains("missing v1"), "{status}");
}
#[test]
fn a_ledger_that_is_not_this_ledger_is_malformed_rather_than_behind() {
let duplicated = classify(&[recorded(1), recorded(1)]);
assert!(
matches!(duplicated, SchemaStatus::Malformed { .. }),
"two rows for one version is not a history: {duplicated:?}"
);
assert!(!duplicated.is_migratable());
let zeroed = classify(&[foreign(0, "control_plane_0000", "sha256:0")]);
assert!(
matches!(zeroed, SchemaStatus::Malformed { .. }),
"versions start at 1: {zeroed:?}"
);
assert!(
zeroed.to_string().contains("not the one this build writes"),
"{zeroed}"
);
}
#[test]
fn an_empty_ledger_is_refused_while_an_absent_one_pends_every_shipped_version() {
let empty = classify(&[]);
assert_eq!(empty, SchemaStatus::Unrecorded);
assert!(
!empty.is_migratable() && !empty.is_current(),
"an empty ledger must not be migrated from zero: {empty:?}"
);
assert!(
pending(&empty).is_empty(),
"nothing is pending against an empty ledger, or an apply would replay every file"
);
let rendered = empty.to_string();
for expected in [
"records no migrations",
"drop the empty",
"axond migrate adopt",
"axond migrate apply",
] {
assert!(
rendered.contains(expected),
"the refusal has to name the action to take, missing `{expected}`: {rendered}"
);
}
assert_eq!(
pending(&SchemaStatus::Absent),
MIGRATIONS.iter().map(|m| m.version).collect::<Vec<_>>()
);
for refused in [
SchemaStatus::Unrecorded,
SchemaStatus::Ahead {
applied: 9,
required: 1,
},
SchemaStatus::Drifted {
version: 1,
expected: MIGRATIONS[0].checksum(),
found: Checksum::of(b"edited"),
},
SchemaStatus::Current { version: 1 },
] {
assert!(
pending(&refused).is_empty(),
"nothing is pending against {refused:?}: an apply must not write there"
);
}
}
#[test]
fn the_shipped_ddl_is_the_migration_this_build_applies() {
let ddl = MIGRATIONS[0].sql;
for object in [
"axond_cp_schema_migration",
"axond_cp_blob",
"axond_cp_resource_version",
"axond_cp_resource_dependency",
"axond_cp_mutation",
"axond_cp_revision",
"axond_cp_revision_entry",
"axond_cp_revision_blob",
"axond_cp_audit_event",
"axond_cp_idempotency",
"axond_cp_head",
] {
assert!(
ddl.contains(&format!("CREATE TABLE IF NOT EXISTS {object}")),
"the journal's {object} table is missing from the shipped DDL"
);
}
}
#[test]
fn a_migrations_declared_tables_are_read_out_of_the_shipped_ddl() {
let declared = MIGRATIONS[0].relations();
assert_eq!(
declared,
vec![
"axond_cp_schema_migration",
"axond_cp_blob",
"axond_cp_resource_version",
"axond_cp_resource_dependency",
"axond_cp_mutation",
"axond_cp_revision",
"axond_cp_revision_entry",
"axond_cp_revision_blob",
"axond_cp_audit_event",
"axond_cp_idempotency",
"axond_cp_head",
],
"the tables adoption looks for are the ones the shipped file creates"
);
assert_eq!(
evidence(&MIGRATIONS[0]),
Some(vec![
present(table("axond_cp_blob")),
present(table("axond_cp_resource_version")),
present(index("axond_cp_resource_version_tenant_idx")),
present(table("axond_cp_resource_dependency")),
present(table("axond_cp_mutation")),
present(table("axond_cp_revision")),
present(index("axond_cp_revision_single_root_idx")),
present(table("axond_cp_revision_entry")),
present(table("axond_cp_revision_blob")),
present(table("axond_cp_audit_event")),
present(index("axond_cp_audit_event_revision_idx")),
present(table("axond_cp_idempotency")),
present(index("axond_cp_idempotency_expires_at_idx")),
present(table("axond_cp_head")),
present(seed("axond_cp_head")),
]),
"every statement of the shipped file has to be something adoption confirms"
);
for migration in MIGRATIONS.iter() {
assert!(
evidence(migration)
.is_some_and(|declared| declared.iter().any(|item| item.present)),
"v{} contains a statement adoption cannot confirm, so no database can be adopted \
while it ships",
migration.version
);
}
}
#[test]
fn the_tenancy_migrations_columns_constraints_and_policies_are_all_confirmable() {
let declared = evidence(&MIGRATIONS[1]).expect(
"v2's statements have to be confirmable, or `adopt` refuses every v2 deployment",
);
for expected in [
present(table("axond_cp_tenant")),
present(index("axond_cp_tenant_slug_idx")),
present(column("axond_cp_mutation", "actor_tenant_id")),
present(column("axond_cp_audit_event", "actor_principal_id")),
replaced(constraint(
"axond_cp_mutation",
"axond_cp_mutation_actor_attribution",
)),
gone(constraint(
"axond_cp_mutation",
"axond_cp_mutation_actor_kind_check",
)),
replaced(policy("axond_cp_tenant", "axond_cp_tenant_isolation")),
] {
assert!(
declared.contains(&expected),
"v2 has to be adoptable on {}: {declared:#?}",
named_thing(&expected.what)
);
}
for guarded in [
"axond_cp_head",
"axond_cp_revision",
"axond_cp_revision_entry",
"axond_cp_revision_blob",
"axond_cp_blob",
"axond_cp_resource_dependency",
] {
for expected in [
present(Evidence::Guarded(guarded.to_owned())),
present(Evidence::Forced(guarded.to_owned())),
replaced(policy(guarded, &format!("{guarded}_isolation"))),
] {
assert!(
declared.contains(&expected),
"the `DO` block's effect on `{guarded}` has to be evidence: {}",
named_thing(&expected.what)
);
}
}
}
#[test]
fn a_statement_whose_effect_cannot_be_confirmed_makes_its_migration_unadoptable() {
const CONFIRMABLE: Migration = Migration {
version: 1,
name: "confirmable",
sql: "CREATE TABLE IF NOT EXISTS first (id integer);\n\
-- A comment mentioning ALTER TABLE and a ';' should not matter.\n\
CREATE UNIQUE INDEX IF NOT EXISTS first_id ON first ((id IS NULL));\n\
INSERT INTO first (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n",
};
assert_eq!(
evidence(&CONFIRMABLE),
Some(vec![
present(table("first")),
present(index("first_id")),
present(seed("first")),
])
);
for sql in [
"CREATE TABLE IF NOT EXISTS first (id integer);\n\
ALTER TABLE first ALTER COLUMN id SET DEFAULT 1;\n",
"CREATE TABLE IF NOT EXISTS first (id integer);\n\
ALTER TABLE first ADD CHECK (id > 0);\n",
"CREATE TABLE IF NOT EXISTS first (id integer);\nUPDATE second SET n = 1;\n",
"CREATE TABLE IF NOT EXISTS first (id integer);\nINSERT INTO first (id) VALUES (1);\n",
"DROP TABLE second;\n",
"/* create table first, if /* nested */ missing */\n\
UPDATE first SET note = 'x';\n",
"CREATE FUNCTION f() RETURNS trigger AS $body$\n\
BEGIN CREATE TABLE first (id integer); RETURN NULL; END;\n\
$body$ LANGUAGE plpgsql;\n",
] {
let migration = Migration {
version: 2,
name: "mixed",
sql,
};
assert_eq!(
evidence(&migration),
None,
"a statement whose effect nothing can confirm must void the whole migration: {sql}"
);
}
const COMMENTED: Migration = Migration {
version: 3,
name: "commented",
sql: "CREATE TABLE IF NOT EXISTS first (id integer);;\n\
-- Why this table exists, after the last statement.\n\
-- And a second line of it.\n",
};
assert_eq!(evidence(&COMMENTED), Some(vec![present(table("first"))]));
const TIGHT: Migration = Migration {
version: 4,
name: "tight",
sql: "CREATE TABLE IF NOT EXISTS second--the only row holder\n\
(id integer PRIMARY KEY, note text);\n\
INSERT INTO second (id, note) VALUES (1, 'only')\n\
ON CONFLICT (id) DO NOTHING--idempotent by construction\n\
;\n",
};
assert_eq!(
evidence(&TIGHT),
Some(vec![present(table("second")), present(seed("second"))])
);
}
#[test]
fn an_alter_is_read_clause_by_clause_and_a_drop_is_confirmed_by_absence() {
const ALTERED: Migration = Migration {
version: 1,
name: "altered",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n\
ALTER TABLE one\n\
ADD COLUMN IF NOT EXISTS note text NULL,\n\
ADD COLUMN IF NOT EXISTS more text NULL;\n\
ALTER TABLE ONLY one\n\
DROP CONSTRAINT IF EXISTS one_note_ck,\n\
ADD CONSTRAINT one_note_ck CHECK (note IS NULL OR more IS NULL),\n\
ENABLE ROW LEVEL SECURITY,\n\
FORCE ROW LEVEL SECURITY;\n\
DROP POLICY IF EXISTS one_isolation ON one;\n\
CREATE POLICY one_isolation ON one USING (id > 0);\n",
};
assert_eq!(
evidence(&ALTERED),
Some(vec![
present(table("one")),
present(column("one", "note")),
present(column("one", "more")),
replaced(constraint("one", "one_note_ck")),
present(Evidence::Guarded("one".to_owned())),
present(Evidence::Forced("one".to_owned())),
replaced(policy("one", "one_isolation")),
])
);
const REMOVED: Migration = Migration {
version: 1,
name: "removed",
sql: "ALTER TABLE one DROP COLUMN note, DISABLE ROW LEVEL SECURITY;\n\
DROP POLICY one_isolation ON one;\n",
};
assert_eq!(
evidence(&REMOVED),
Some(vec![
gone(column("one", "note")),
gone(Evidence::Guarded("one".to_owned())),
gone(policy("one", "one_isolation")),
])
);
let Baseline::Inconsistent { message } = reconcile(&[REMOVED], &HashSet::new()) else {
panic!("a migration that only removes things proves nothing by itself");
};
assert!(
message.contains("v1 `removed` contains a statement"),
"the refusal has to name the version nothing can account for: {message}"
);
const ADDS_AND_REMOVES: Migration = Migration {
version: 2,
name: "replaces",
sql: "ALTER TABLE one DROP CONSTRAINT one_note_ck, ADD CONSTRAINT one_note_ck2 \
CHECK (note IS NOT NULL);\n",
};
const CREATES: Migration = Migration {
version: 1,
name: "creates",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n",
};
assert_eq!(
reconcile(&[CREATES, ADDS_AND_REMOVES], &HashSet::new()),
Baseline::Nothing,
"an untouched database is untouched, not part way through the version above it"
);
assert_eq!(
reconcile(&[CREATES, ADDS_AND_REMOVES], &HashSet::from([table("one")])),
Baseline::Applied { versions: vec![1] },
"v1's table is v1's baseline, with v2 still pending"
);
assert_eq!(
reconcile(
&[CREATES, ADDS_AND_REMOVES],
&HashSet::from([table("one"), constraint("one", "one_note_ck2")])
),
Baseline::Applied {
versions: vec![1, 2]
},
"the constraint v2 adds, with the one it drops gone, is v2 applied"
);
const REWRITES: Migration = Migration {
version: 2,
name: "rewrites",
sql: "ALTER TABLE one\n\
DROP CONSTRAINT IF EXISTS one_note_ck,\n\
ADD CONSTRAINT one_note_ck CHECK (note IS NOT NULL),\n\
ADD COLUMN IF NOT EXISTS more text NULL;\n",
};
let v1_only = HashSet::from([table("one"), constraint("one", "one_note_ck")]);
assert_eq!(
reconcile(&[CREATES, REWRITES], &v1_only),
Baseline::Applied { versions: vec![1] },
"a constraint v1 leaves behind too is not evidence v2 rewrote it"
);
let mut applied = v1_only.clone();
applied.insert(column("one", "more"));
assert_eq!(
reconcile(&[CREATES, REWRITES], &applied),
Baseline::Applied {
versions: vec![1, 2]
},
"the column only v2 adds is what says v2 ran"
);
let Baseline::Inconsistent { message } = reconcile(
&[CREATES, REWRITES],
&HashSet::from([table("one"), column("one", "more")]),
) else {
panic!("v2's own constraint has to be required of a database v2 ran on");
};
assert!(
message.contains("`one`'s `one_note_ck` constraint is not present"),
"the refusal has to name the constraint that is missing: {message}"
);
for sql in [
"ALTER TABLE one ALTER COLUMN note TYPE integer;\n",
"ALTER TABLE one ADD note text;\n",
"ALTER TABLE one ADD PRIMARY KEY (id);\n",
"ALTER TABLE one ADD COLUMN note text, ALTER COLUMN id DROP NOT NULL;\n",
"CREATE POLICY one_isolation ON other.one USING (true);\n",
"CREATE POLICY one_isolation FOR SELECT USING (true);\n",
] {
let unconfirmable = Migration {
version: 1,
name: "unconfirmable",
sql,
};
assert_eq!(
evidence(&unconfirmable),
None,
"a clause nothing can be asked about voids its migration: {sql}"
);
}
}
#[test]
fn a_dynamic_loop_is_evidence_for_the_statements_it_renders_and_nothing_else() {
const LOOPED: Migration = Migration {
version: 1,
name: "looped",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n\
DO $$\n\
DECLARE\n\
chained text;\n\
BEGIN\n\
FOREACH chained IN ARRAY ARRAY['one', 'two'] LOOP\n\
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', chained);\n\
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', chained || '_isolation', chained);\n\
EXECUTE format(\n\
'CREATE POLICY %I ON %I USING (%s)',\n\
chained || '_isolation',\n\
chained,\n\
'current_setting(''axond.tenant_id'', true) IS NOT NULL'\n\
);\n\
END LOOP;\n\
END\n\
$$;\n",
};
assert_eq!(
evidence(&LOOPED),
Some(vec![
present(table("one")),
present(Evidence::Guarded("one".to_owned())),
replaced(policy("one", "one_isolation")),
present(Evidence::Guarded("two".to_owned())),
replaced(policy("two", "two_isolation")),
]),
"the loop's evidence is its templates rendered for its own names"
);
for body in [
"IF found THEN EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', chained); END IF;",
"FOREACH chained IN ARRAY (SELECT array_agg(relname) FROM pg_class) LOOP \
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', chained); END LOOP;",
"FOREACH chained IN ARRAY ARRAY['one'] LOOP \
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', other); END LOOP;",
"FOREACH chained IN ARRAY ARRAY['one'] LOOP \
EXECUTE format('UPDATE %I SET note = 1', chained); END LOOP;",
"FOREACH chained IN ARRAY ARRAY['one'] LOOP \
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY'); END LOOP;",
"FOREACH chained IN ARRAY ARRAY['one'] LOOP \
EXECUTE format('CREATE POLICY %I ON %L USING (true)', 'p', chained); END LOOP;",
"FOREACH chained IN ARRAY ARRAY['one'] LOOP \
EXECUTE 'ALTER TABLE one ENABLE ROW LEVEL SECURITY'; END LOOP;",
] {
let block = format!("DO $$\nDECLARE\n chained text;\nBEGIN\n {body}\nEND\n$$");
assert_eq!(
expectations(&block),
None,
"a block outside the one shape this reads is unconfirmable: {block}"
);
}
}
#[test]
fn the_deferred_constraint_migrations_guards_and_cleanups_are_all_confirmable() {
let declared = evidence(&MIGRATIONS[2])
.expect("v3's statements have to be confirmable, or `adopt` refuses every deployment");
for expected in [
gone(index("axond_cp_tenant_slug_idx")),
present(constraint("axond_cp_tenant", "axond_cp_tenant_slug_unique")),
present(constraint(
"axond_cp_project",
"axond_cp_project_slug_unique",
)),
present(constraint(
"axond_cp_principal",
"axond_cp_principal_key_digest_unique",
)),
present(constraint(
"axond_cp_principal",
"axond_cp_principal_project_fkey",
)),
present(constraint(
"axond_cp_mutation",
"axond_cp_mutation_actor_attribution",
)),
replaced(policy("axond_cp_mutation", "axond_cp_mutation_isolation")),
] {
assert!(
declared.contains(&expected),
"v3 has to be adoptable on {}: {declared:#?}",
named_thing(&expected.what)
);
}
let cleared: Vec<&Expectation> = declared
.iter()
.filter(|item| matches!(item.what, Evidence::Stale { .. }))
.collect();
assert_eq!(
cleared.len(),
4,
"each of v3's cleanup loops is evidence: {declared:#?}"
);
assert!(
cleared.iter().all(|item| !item.present && !item.proof),
"a definition being gone is required of a v3 database and proof of nothing"
);
assert!(
cleared.iter().any(|item| matches!(
&item.what,
Evidence::Stale { table, except, .. }
if table == "axond_cp_mutation"
&& except.contains(&"axond_cp_mutation_actor_attribution".to_owned())
)),
"the journal's loop has to admit the check v3 adds itself: {declared:#?}"
);
}
#[test]
fn a_guarded_add_and_a_cleanup_loop_are_read_only_in_the_shapes_they_summarise() {
let block = |body: &str| format!("DO $$\nDECLARE\n stale record;\nBEGIN\n {body}\nEND\n$$");
let guarded = block(
"IF NOT EXISTS (\
SELECT 1 FROM pg_constraint \
WHERE conrelid = 'one'::regclass AND conname = 'one_unique'\
) THEN \
ALTER TABLE one ADD CONSTRAINT one_unique UNIQUE (id) DEFERRABLE; \
END IF;",
);
assert_eq!(
expectations(&guarded),
Some(vec![present(constraint("one", "one_unique"))]),
"a guard that asks about the constraint it adds leaves that constraint"
);
let looped = block(
"FOR stale IN \
SELECT conname FROM pg_constraint \
WHERE conrelid = 'one'::regclass AND contype = 'u' \
LOOP \
EXECUTE format('ALTER TABLE one DROP CONSTRAINT %I', stale.conname); \
END LOOP;",
);
let cleared = expectations(&looped);
let Some([expectation]) = cleared.as_deref() else {
panic!("a loop that drops what its own query names is one piece of evidence");
};
assert!(
matches!(&expectation.what, Evidence::Stale { table, .. } if table == "one")
&& !expectation.present
&& !expectation.proof,
"the loop's evidence is its query naming nothing on `one`: {expectation:?}"
);
for body in [
"IF NOT EXISTS (\
SELECT 1 FROM pg_constraint WHERE conname = 'other_unique'\
) THEN ALTER TABLE one ADD CONSTRAINT one_unique UNIQUE (id); END IF;",
"IF NOT EXISTS (\
UPDATE one SET id = 1 RETURNING id\
) THEN ALTER TABLE one ADD CONSTRAINT one_unique UNIQUE (id); END IF;",
"IF NOT EXISTS (\
SELECT 1 FROM pg_constraint WHERE conname = 'one_unique'\
) THEN UPDATE one SET note = 1; END IF;",
"FOR stale IN SELECT conname FROM pg_constraint WHERE contype = 'u' LOOP \
EXECUTE format('ALTER TABLE one DROP CONSTRAINT %I', stale.conname); \
EXECUTE format('ALTER TABLE one ADD CONSTRAINT %I UNIQUE (id)', stale.conname); \
END LOOP;",
"FOR stale IN SELECT conname FROM pg_constraint WHERE contype = 'u' LOOP \
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT one_unique', 'one'); \
END LOOP;",
"FOR stale IN DELETE FROM pg_constraint RETURNING conname LOOP \
EXECUTE format('ALTER TABLE one DROP CONSTRAINT %I', stale.conname); \
END LOOP;",
"FOR stale IN SELECT relname AS conname FROM pg_class LOOP \
EXECUTE format('ALTER TABLE one DROP CONSTRAINT %I', stale.conname); \
END LOOP;",
"FOR stale IN SELECT oid FROM pg_constraint LOOP \
EXECUTE format('ALTER TABLE one DROP CONSTRAINT %I', stale.oid); \
END LOOP;",
] {
let block = block(body);
assert_eq!(
expectations(&block),
None,
"a block outside the shapes this reads is unconfirmable: {block}"
);
}
}
#[test]
fn a_later_migration_taking_an_earlier_ones_object_away_is_read_as_the_prefix_it_is() {
const V1: Migration = Migration {
version: 1,
name: "first",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n",
};
const V2: Migration = Migration {
version: 2,
name: "indexed",
sql: "CREATE TABLE IF NOT EXISTS two (id integer);\n\
CREATE INDEX IF NOT EXISTS one_id ON one (id);\n",
};
const V3: Migration = Migration {
version: 3,
name: "constrained",
sql: "DROP INDEX IF EXISTS one_id;\n\
ALTER TABLE one ADD CONSTRAINT one_id_unique UNIQUE (id);\n",
};
let shipped = [V1, V2, V3];
let one = table("one");
let two = table("two");
let indexed = index("one_id");
let constrained = constraint("one", "one_id_unique");
assert_eq!(
reconcile(
&shipped,
&HashSet::from([one.clone(), two.clone(), indexed.clone()])
),
Baseline::Applied {
versions: vec![1, 2]
},
"a database with the index v3 drops is one v3 has not run"
);
assert_eq!(
reconcile(
&shipped,
&HashSet::from([one.clone(), two.clone(), constrained.clone()])
),
Baseline::Applied {
versions: vec![1, 2, 3]
},
"the index being gone is what v3 leaves, so its absence is not a hole"
);
let Baseline::Inconsistent { message } =
reconcile(&shipped, &HashSet::from([one.clone(), two.clone()]))
else {
panic!("a database part-way through the replacement has no adoptable baseline");
};
assert!(
message.contains("`one_id` is not present"),
"the refusal names the index the prefix it claims leaves behind: {message}"
);
assert_eq!(
reconcile(&shipped, &HashSet::from([one, two, indexed, constrained])),
Baseline::Inconsistent {
message: "v3 `constrained` is only partly applied: `one_id` is still present, so \
this build cannot record it as applied and cannot apply it over what is \
there either. Finish or undo that migration by hand, then re-run."
.to_owned()
},
"a database with both is one where v3's `DROP INDEX` has not run"
);
}
#[test]
fn declared_tables_are_parsed_from_either_create_form_and_nothing_else() {
const MIXED: Migration = Migration {
version: 7,
name: "fixture",
sql: "CREATE TABLE IF NOT EXISTS first (id integer);\n\
CREATE TABLE second\n(id integer);\n\
CREATE INDEX IF NOT EXISTS second_id ON second (id);\n\
ALTER TABLE first ADD COLUMN note text;\n\
CREATE TABLE IF NOT EXISTS first (id integer);\n",
};
assert_eq!(MIXED.relations(), vec!["first", "second"]);
for sql in [
"CREATE INDEX ON second (id);\n",
"CREATE UNIQUE INDEX CONCURRENTLY ON second (id);\n",
"CREATE TABLE other.third (id integer);\n",
"INSERT INTO other.third (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n",
] {
let unnameable = Migration {
version: 8,
name: "unnameable",
sql,
};
assert_eq!(
evidence(&unnameable),
None,
"an object this parse cannot name is unconfirmable, not absent: {sql}"
);
}
}
#[test]
fn a_migration_no_object_can_account_for_blocks_adoption_of_the_whole_history() {
const V1: Migration = Migration {
version: 1,
name: "first",
sql: "CREATE TABLE IF NOT EXISTS axond_cp_schema_migration (version integer);\n\
CREATE TABLE IF NOT EXISTS one (id integer);\n",
};
const V2: Migration = Migration {
version: 2,
name: "backfill",
sql: "UPDATE one SET note = 'x';\n",
};
const V3: Migration = Migration {
version: 3,
name: "third",
sql: "CREATE TABLE IF NOT EXISTS three (id integer);\n",
};
let shipped = &[V1, V2, V3];
let one = table("one");
let three = table("three");
for confirmed in [
HashSet::from([one.clone()]),
HashSet::from([one.clone(), three.clone()]),
HashSet::from([three.clone()]),
HashSet::new(),
] {
let Baseline::Inconsistent { message } = reconcile(shipped, &confirmed) else {
panic!("a history with an unconfirmable migration has no adoptable baseline");
};
assert!(
message.contains("v2 `backfill` contains a statement")
&& message.contains("re-run it"),
"the refusal has to name the version and why nothing under it is safe: {message}"
);
}
let confirmable = &[V1, V3];
assert_eq!(
reconcile(confirmable, &HashSet::from([one])),
Baseline::Applied { versions: vec![1] }
);
assert_eq!(reconcile(confirmable, &HashSet::new()), Baseline::Nothing);
let Baseline::Inconsistent { message } = reconcile(confirmable, &HashSet::from([three]))
else {
panic!("a hole in the applied prefix is not a baseline");
};
assert!(
message.contains("not a prefix"),
"the refusal has to say why the objects describe no baseline: {message}"
);
}
#[test]
fn a_second_seed_into_an_already_seeded_table_blocks_adoption() {
const V1: Migration = Migration {
version: 1,
name: "first",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n\
INSERT INTO one (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n",
};
const V2: Migration = Migration {
version: 2,
name: "second seed",
sql: "INSERT INTO one (id) VALUES (2) ON CONFLICT (id) DO NOTHING;\n",
};
let one = table("one");
let seeded = seed("one");
for confirmed in [
HashSet::from([one.clone(), seeded.clone()]),
HashSet::from([one.clone()]),
HashSet::new(),
] {
let Baseline::Inconsistent { message } = reconcile(&[V1, V2], &confirmed) else {
panic!("a seed no row can be attributed to has no adoptable baseline");
};
assert!(
message.contains("which the shipped history seeds more than once"),
"the refusal has to say why a row in it proves nothing: {message}"
);
}
const TWICE: Migration = Migration {
version: 1,
name: "twice",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n\
INSERT INTO one (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n\
INSERT INTO one (id) VALUES (2) ON CONFLICT (id) DO NOTHING;\n",
};
assert_eq!(
evidence(&TWICE),
Some(vec![
present(one.clone()),
present(seeded.clone()),
present(seeded.clone()),
]),
"a repeated seed is two expectations, unlike a relation declared twice"
);
let Baseline::Inconsistent { message } =
reconcile(&[TWICE], &HashSet::from([one.clone(), seeded.clone()]))
else {
panic!("a file seeding one table twice has no adoptable baseline");
};
assert!(
message.contains("which the shipped history seeds more than once"),
"the refusal has to say why a row in it proves nothing: {message}"
);
assert_eq!(
reconcile(&[V1], &HashSet::from([one, seeded])),
Baseline::Applied { versions: vec![1] }
);
}
#[test]
fn an_object_more_than_one_migration_declares_blocks_adoption() {
const V1: Migration = Migration {
version: 1,
name: "first",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n",
};
const V2: Migration = Migration {
version: 2,
name: "re-declares",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n\
CREATE INDEX IF NOT EXISTS one_id ON one (id);\n",
};
let one = table("one");
let declared = index("one_id");
for confirmed in [
HashSet::from([one.clone(), declared.clone()]),
HashSet::from([one.clone()]),
HashSet::new(),
] {
let Baseline::Inconsistent { message } = reconcile(&[V1, V2], &confirmed) else {
panic!("an object no version can be attributed to has no adoptable baseline");
};
assert!(
message.contains("acts on `one`, which more than one shipped migration acts on"),
"the refusal has to say why the object's presence proves nothing: {message}"
);
}
const V2_OWN: Migration = Migration {
version: 2,
name: "own objects",
sql: "CREATE TABLE IF NOT EXISTS two (id integer);\n",
};
assert_eq!(
reconcile(&[V1, V2_OWN], &HashSet::from([one, table("two")])),
Baseline::Applied {
versions: vec![1, 2]
}
);
}
#[test]
fn a_region_that_does_not_close_where_this_parse_says_makes_the_migration_unadoptable() {
for sql in [
"CREATE TABLE IF NOT EXISTS one (id integer, note text DEFAULT E'a\\'b');\n\
ALTER TABLE one ADD COLUMN more text;\n",
"CREATE TABLE IF NOT EXISTS one (id integer);\n/* never closed\n",
"CREATE TABLE IF NOT EXISTS one (id integer);\nINSERT INTO one VALUES ('open);\n",
"CREATE FUNCTION f() RETURNS void AS $body$ BEGIN END;\n",
] {
let unlexable = Migration {
version: 9,
name: "unlexable",
sql,
};
assert_eq!(
evidence(&unlexable),
None,
"a region this parse cannot close makes the file unconfirmable: {sql}"
);
}
assert!(lexed(
"CREATE TABLE IF NOT EXISTS one (id integer);\n-- and that is all"
));
assert!(lexed("CREATE POLICY p ON one USING (id = $1);\n"));
let inside = Migration {
version: 9,
name: "unlexable_block",
sql: "DO $$\nBEGIN\n \
EXECUTE format('ALTER TABLE one ADD COLUMN note text DEFAULT E''a\\''b''');\n \
ALTER TABLE one ENABLE ROW LEVEL SECURITY;\nEND\n$$;\n",
};
assert_eq!(
evidence(&inside),
None,
"a region a block's own body does not close makes the migration unconfirmable"
);
}
#[test]
fn a_skipped_middle_version_is_refused_rather_than_recorded_as_the_prefix_below_it() {
const V1: Migration = Migration {
version: 1,
name: "first",
sql: "CREATE TABLE IF NOT EXISTS one (id integer);\n",
};
const V2: Migration = Migration {
version: 2,
name: "second",
sql: "CREATE TABLE IF NOT EXISTS two (id integer);\n",
};
const V3: Migration = Migration {
version: 3,
name: "third",
sql: "CREATE TABLE IF NOT EXISTS three (id integer);\n",
};
let shipped = [V1, V2, V3];
let Baseline::Inconsistent { message } =
reconcile(&shipped, &HashSet::from([table("one"), table("three")]))
else {
panic!("v3's table without v2's is not a prefix of the shipped history");
};
assert!(
message.contains("v3 `third` declares objects that are present"),
"the refusal names the version whose objects are there out of order: {message}"
);
assert_eq!(
reconcile(&shipped, &HashSet::from([table("one")])),
Baseline::Applied { versions: vec![1] },
"v1 alone is the ordinary hand-applied prefix"
);
}
}