mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
691 lines
25 KiB
Rust
691 lines
25 KiB
Rust
//! Embedded SQLx migrations for Buzz.
|
|
//!
|
|
//! Fresh deployments apply the checked-in SQL files under `migrations/`. The
|
|
//! multi-tenant rewrite owns a clean consolidated `0001`; legacy single-tenant
|
|
//! cutover/backfill is a separate operator script, not startup migration state.
|
|
|
|
use sqlx::PgPool;
|
|
|
|
use crate::Result;
|
|
|
|
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
|
|
|
|
/// Run all pending Buzz database migrations.
|
|
pub async fn run_migrations(pool: &PgPool) -> Result<()> {
|
|
MIGRATOR.run(pool).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::BTreeSet;
|
|
|
|
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ConstraintKind {
|
|
ForeignKey,
|
|
PrimaryKey,
|
|
Unique,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ConstraintLint {
|
|
table: String,
|
|
kind: ConstraintKind,
|
|
description: String,
|
|
columns: Vec<String>,
|
|
}
|
|
|
|
fn migration_sql() -> &'static str {
|
|
MIGRATOR
|
|
.iter()
|
|
.find(|migration| migration.version == 1)
|
|
.expect("initial migration must exist")
|
|
.sql
|
|
.as_str()
|
|
}
|
|
|
|
fn strip_sql_comments(sql: &str) -> String {
|
|
sql.lines()
|
|
.map(|line| line.split_once("--").map_or(line, |(before, _)| before))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn normalize_sql(sql: &str) -> String {
|
|
strip_sql_comments(sql)
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
.to_ascii_lowercase()
|
|
}
|
|
|
|
fn split_sql_statements(sql: &str) -> Vec<String> {
|
|
let sql = strip_sql_comments(sql);
|
|
let bytes = sql.as_bytes();
|
|
let mut statements = Vec::new();
|
|
let mut start = 0usize;
|
|
let mut idx = 0usize;
|
|
let mut in_single_quote = false;
|
|
let mut in_dollar_quote = false;
|
|
|
|
while idx < bytes.len() {
|
|
match bytes[idx] {
|
|
b'\'' if !in_dollar_quote => {
|
|
in_single_quote = !in_single_quote;
|
|
idx += 1;
|
|
}
|
|
b'$' if !in_single_quote && idx + 1 < bytes.len() && bytes[idx + 1] == b'$' => {
|
|
in_dollar_quote = !in_dollar_quote;
|
|
idx += 2;
|
|
}
|
|
b';' if !in_single_quote && !in_dollar_quote => {
|
|
let statement = sql[start..idx].trim();
|
|
if !statement.is_empty() {
|
|
statements.push(statement.to_owned());
|
|
}
|
|
start = idx + 1;
|
|
idx += 1;
|
|
}
|
|
_ => idx += 1,
|
|
}
|
|
}
|
|
|
|
let tail = sql[start..].trim();
|
|
if !tail.is_empty() {
|
|
statements.push(tail.to_owned());
|
|
}
|
|
|
|
statements
|
|
}
|
|
|
|
fn find_matching_paren(sql: &str, open: usize) -> Option<usize> {
|
|
let mut depth = 0usize;
|
|
for (offset, byte) in sql.as_bytes()[open..].iter().enumerate() {
|
|
match byte {
|
|
b'(' => depth += 1,
|
|
b')' => {
|
|
depth = depth.checked_sub(1)?;
|
|
if depth == 0 {
|
|
return Some(open + offset);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn split_top_level_csv(input: &str) -> Vec<String> {
|
|
let mut parts = Vec::new();
|
|
let mut start = 0usize;
|
|
let mut depth = 0usize;
|
|
for (idx, byte) in input.bytes().enumerate() {
|
|
match byte {
|
|
b'(' => depth += 1,
|
|
b')' => depth = depth.saturating_sub(1),
|
|
b',' if depth == 0 => {
|
|
parts.push(input[start..idx].trim().to_owned());
|
|
start = idx + 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let tail = input[start..].trim();
|
|
if !tail.is_empty() {
|
|
parts.push(tail.to_owned());
|
|
}
|
|
parts
|
|
}
|
|
|
|
fn identifier_after_keyword(statement: &str, keyword: &str) -> Option<String> {
|
|
let lower = statement.to_ascii_lowercase();
|
|
let keyword_pos = lower.find(keyword)?;
|
|
let mut remainder = statement[keyword_pos + keyword.len()..].trim_start();
|
|
for prefix in ["if not exists", "if exists", "only"] {
|
|
if remainder.to_ascii_lowercase().starts_with(prefix) {
|
|
remainder = remainder[prefix.len()..].trim_start();
|
|
}
|
|
}
|
|
|
|
let identifier = remainder
|
|
.split(|ch: char| ch.is_whitespace() || ch == '(')
|
|
.next()?
|
|
.trim_matches('"')
|
|
.rsplit('.')
|
|
.next()?
|
|
.trim_matches('"')
|
|
.to_ascii_lowercase();
|
|
(!identifier.is_empty()).then_some(identifier)
|
|
}
|
|
|
|
fn first_parenthesized_columns(input: &str) -> Vec<String> {
|
|
let Some(open) = input.find('(') else {
|
|
return Vec::new();
|
|
};
|
|
let Some(close) = find_matching_paren(input, open) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
split_top_level_csv(&input[open + 1..close])
|
|
.into_iter()
|
|
.filter_map(|column| {
|
|
let name = column
|
|
.trim()
|
|
.trim_matches('"')
|
|
.split_whitespace()
|
|
.next()?
|
|
.trim_matches('"')
|
|
.to_ascii_lowercase();
|
|
(!name.is_empty()).then_some(name)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn column_definition_name(definition: &str) -> Option<String> {
|
|
let trimmed = definition.trim();
|
|
let lower = trimmed.to_ascii_lowercase();
|
|
if lower.starts_with("constraint ")
|
|
|| lower.starts_with("primary key")
|
|
|| lower.starts_with("foreign key")
|
|
|| lower.starts_with("unique")
|
|
|| lower.starts_with("check ")
|
|
|| lower.starts_with("exclude ")
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let name = trimmed
|
|
.split_whitespace()
|
|
.next()?
|
|
.trim_matches('"')
|
|
.to_ascii_lowercase();
|
|
(!name.is_empty()).then_some(name)
|
|
}
|
|
|
|
fn create_table_body(statement: &str) -> Option<(String, Vec<String>)> {
|
|
let table = identifier_after_keyword(statement, "create table")?;
|
|
let open = statement.find('(')?;
|
|
let close = find_matching_paren(statement, open)?;
|
|
Some((table, split_top_level_csv(&statement[open + 1..close])))
|
|
}
|
|
|
|
fn create_table_definitions(sql: &str) -> Vec<(String, Vec<String>)> {
|
|
split_sql_statements(sql)
|
|
.into_iter()
|
|
.filter_map(|statement| {
|
|
let normalized = statement.trim_start().to_ascii_lowercase();
|
|
if !normalized.starts_with("create table") || normalized.contains(" partition of ")
|
|
{
|
|
return None;
|
|
}
|
|
create_table_body(&statement)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn create_tables(sql: &str) -> BTreeSet<String> {
|
|
create_table_definitions(sql)
|
|
.into_iter()
|
|
.map(|(table, _)| table)
|
|
.collect()
|
|
}
|
|
|
|
fn table_has_not_null_community_id(definitions: &[String]) -> bool {
|
|
definitions.iter().any(|definition| {
|
|
column_definition_name(definition).as_deref() == Some("community_id")
|
|
&& normalize_sql(definition).contains("not null")
|
|
})
|
|
}
|
|
|
|
fn operator_global_tables(sql: &str) -> BTreeSet<String> {
|
|
let mut globals = BTreeSet::new();
|
|
let normalized = normalize_sql(sql);
|
|
let Some(insert_pos) = normalized.find("insert into _operator_global_tables") else {
|
|
return globals;
|
|
};
|
|
|
|
for value in [
|
|
"communities",
|
|
"rate_limit_violations",
|
|
"_operator_global_tables",
|
|
] {
|
|
if normalized[insert_pos..].contains(&format!("'{value}'")) {
|
|
globals.insert(value.to_owned());
|
|
}
|
|
}
|
|
|
|
globals
|
|
}
|
|
|
|
fn scoped_tables(sql: &str) -> BTreeSet<String> {
|
|
let globals = operator_global_tables(sql);
|
|
create_tables(sql)
|
|
.into_iter()
|
|
.filter(|table| !globals.contains(table))
|
|
.collect()
|
|
}
|
|
|
|
fn constraint_lint_for_definition(table: &str, definition: &str) -> Option<ConstraintLint> {
|
|
let normalized = normalize_sql(definition);
|
|
let definition_without_name = if normalized.starts_with("constraint ") {
|
|
let after_constraint = definition
|
|
.trim_start()
|
|
.splitn(3, char::is_whitespace)
|
|
.nth(2)
|
|
.unwrap_or("");
|
|
normalize_sql(after_constraint)
|
|
} else {
|
|
normalized.clone()
|
|
};
|
|
|
|
if definition_without_name.starts_with("primary key") {
|
|
Some(ConstraintLint {
|
|
table: table.to_owned(),
|
|
kind: ConstraintKind::PrimaryKey,
|
|
description: definition.to_owned(),
|
|
columns: first_parenthesized_columns(&definition_without_name),
|
|
})
|
|
} else if definition_without_name.starts_with("unique") {
|
|
Some(ConstraintLint {
|
|
table: table.to_owned(),
|
|
kind: ConstraintKind::Unique,
|
|
description: definition.to_owned(),
|
|
columns: first_parenthesized_columns(&definition_without_name),
|
|
})
|
|
} else if definition_without_name.starts_with("foreign key") {
|
|
Some(ConstraintLint {
|
|
table: table.to_owned(),
|
|
kind: ConstraintKind::ForeignKey,
|
|
description: definition.to_owned(),
|
|
columns: first_parenthesized_columns(&definition_without_name),
|
|
})
|
|
} else if normalized.contains(" primary key") {
|
|
column_definition_name(definition).map(|column| ConstraintLint {
|
|
table: table.to_owned(),
|
|
kind: ConstraintKind::PrimaryKey,
|
|
description: definition.to_owned(),
|
|
columns: vec![column],
|
|
})
|
|
} else if normalized.contains(" references ") {
|
|
column_definition_name(definition).map(|column| ConstraintLint {
|
|
table: table.to_owned(),
|
|
kind: ConstraintKind::ForeignKey,
|
|
description: definition.to_owned(),
|
|
columns: vec![column],
|
|
})
|
|
} else if normalized.contains(" unique") {
|
|
column_definition_name(definition).map(|column| ConstraintLint {
|
|
table: table.to_owned(),
|
|
kind: ConstraintKind::Unique,
|
|
description: definition.to_owned(),
|
|
columns: vec![column],
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn table_constraints(sql: &str, scoped_tables: &BTreeSet<String>) -> Vec<ConstraintLint> {
|
|
create_table_definitions(sql)
|
|
.into_iter()
|
|
.filter(|(table, _)| scoped_tables.contains(table))
|
|
.flat_map(|(table, definitions)| {
|
|
definitions.into_iter().filter_map(move |definition| {
|
|
constraint_lint_for_definition(&table, &definition)
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn alter_table_constraints(sql: &str, scoped_tables: &BTreeSet<String>) -> Vec<ConstraintLint> {
|
|
split_sql_statements(sql)
|
|
.into_iter()
|
|
.filter_map(|statement| {
|
|
let normalized = normalize_sql(&statement);
|
|
if !normalized.starts_with("alter table") {
|
|
return None;
|
|
}
|
|
|
|
let table = identifier_after_keyword(&statement, "alter table")?;
|
|
if !scoped_tables.contains(&table) {
|
|
return None;
|
|
}
|
|
|
|
let add_pos = normalized.find(" add ")?;
|
|
let definition = normalized[add_pos + " add ".len()..].trim();
|
|
constraint_lint_for_definition(&table, definition)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn unique_indexes(sql: &str, scoped_tables: &BTreeSet<String>) -> Vec<ConstraintLint> {
|
|
split_sql_statements(sql)
|
|
.into_iter()
|
|
.filter_map(|statement| {
|
|
let normalized = normalize_sql(&statement);
|
|
if !normalized.starts_with("create unique index") {
|
|
return None;
|
|
}
|
|
|
|
let lower_statement = statement.to_ascii_lowercase();
|
|
let on_pos = lower_statement.find(" on ")?;
|
|
let table = statement[on_pos + " on ".len()..]
|
|
.trim_start()
|
|
.split(|ch: char| ch.is_whitespace() || ch == '(')
|
|
.next()?
|
|
.trim_matches('"')
|
|
.rsplit('.')
|
|
.next()?
|
|
.trim_matches('"')
|
|
.to_ascii_lowercase();
|
|
|
|
scoped_tables.contains(&table).then(|| ConstraintLint {
|
|
table,
|
|
kind: ConstraintKind::Unique,
|
|
description: statement.clone(),
|
|
columns: first_parenthesized_columns(&statement[on_pos + " on ".len()..]),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn scoped_constraint_lints(sql: &str, scoped_tables: &BTreeSet<String>) -> Vec<ConstraintLint> {
|
|
let mut constraints = table_constraints(sql, scoped_tables);
|
|
constraints.extend(alter_table_constraints(sql, scoped_tables));
|
|
constraints.extend(unique_indexes(sql, scoped_tables));
|
|
constraints
|
|
}
|
|
|
|
fn is_allowed_partition_primary_key_exception(constraint: &ConstraintLint) -> bool {
|
|
constraint.table == "delivery_log"
|
|
&& constraint.kind == ConstraintKind::PrimaryKey
|
|
&& constraint.columns == ["delivered_at", "id"]
|
|
}
|
|
|
|
fn scoped_constraint_violations(sql: &str) -> Vec<ConstraintLint> {
|
|
let scoped_tables = scoped_tables(sql);
|
|
scoped_constraint_lints(sql, &scoped_tables)
|
|
.into_iter()
|
|
.filter(|constraint| {
|
|
if is_allowed_partition_primary_key_exception(constraint) {
|
|
return false;
|
|
}
|
|
constraint.columns.first().map(String::as_str) != Some("community_id")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn has_channels_community_id_immutability_guard(sql: &str) -> bool {
|
|
let normalized = normalize_sql(sql);
|
|
normalized.contains("create trigger")
|
|
&& normalized.contains("before update")
|
|
&& normalized.contains(" on channels")
|
|
&& normalized.contains("community_id")
|
|
&& normalized.contains("old.community_id")
|
|
&& normalized.contains("new.community_id")
|
|
&& normalized.contains("raise exception")
|
|
}
|
|
|
|
fn forbidden_channels_community_id_mutations(sql: &str) -> Vec<String> {
|
|
split_sql_statements(sql)
|
|
.into_iter()
|
|
.filter(|statement| {
|
|
let normalized = normalize_sql(statement);
|
|
let updates_channels =
|
|
identifier_after_keyword(statement, "update").as_deref() == Some("channels");
|
|
let mutates_with_update = updates_channels
|
|
&& normalized.contains(" set ")
|
|
&& normalized.contains("community_id");
|
|
let alters_channels = identifier_after_keyword(statement, "alter table").as_deref()
|
|
== Some("channels");
|
|
let drops_channels = identifier_after_keyword(statement, "drop table").as_deref()
|
|
== Some("channels");
|
|
let drops_or_rewrites_column = alters_channels
|
|
&& (normalized.contains("drop column community_id")
|
|
|| normalized.contains("alter column community_id")
|
|
|| normalized.contains("rename column community_id")
|
|
|| normalized.contains("rename community_id")
|
|
|| normalized.contains("drop trigger")
|
|
|| normalized.contains("disable trigger"));
|
|
|
|
mutates_with_update || drops_or_rewrites_column || drops_channels
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_migrator_contains_consolidated_initial_schema() {
|
|
let migrations: Vec<_> = MIGRATOR.iter().collect();
|
|
|
|
assert_eq!(migrations.len(), 1);
|
|
assert_eq!(migrations[0].version, 1);
|
|
assert_eq!(&*migrations[0].description, "initial schema");
|
|
assert!(migrations[0]
|
|
.sql
|
|
.as_str()
|
|
.contains("CREATE TABLE communities"));
|
|
assert!(migrations[0].sql.as_str().contains("CREATE TABLE channels"));
|
|
assert!(migrations[0]
|
|
.sql
|
|
.as_str()
|
|
.contains("CREATE TABLE scheduled_workflow_fires"));
|
|
assert!(migrations[0]
|
|
.sql
|
|
.as_str()
|
|
.contains("CREATE TABLE audit_log"));
|
|
assert!(migrations[0]
|
|
.sql
|
|
.as_str()
|
|
.contains("CREATE TABLE _operator_global_tables"));
|
|
assert!(migrations[0]
|
|
.sql
|
|
.as_str()
|
|
.contains("search_tsv TSVECTOR GENERATED ALWAYS"));
|
|
}
|
|
|
|
#[test]
|
|
fn migration_lint_detects_tables_missing_community_id_by_default() {
|
|
let sql = r#"
|
|
CREATE TABLE communities (id UUID PRIMARY KEY);
|
|
CREATE TABLE widgets (id UUID PRIMARY KEY);
|
|
CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL);
|
|
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
|
('communities', 'tenant registry'),
|
|
('_operator_global_tables', 'registry');
|
|
"#;
|
|
|
|
let definitions = create_table_definitions(sql);
|
|
let scoped = scoped_tables(sql);
|
|
let missing = definitions
|
|
.into_iter()
|
|
.filter(|(table, _)| scoped.contains(table))
|
|
.filter(|(_, definitions)| !table_has_not_null_community_id(definitions))
|
|
.map(|(table, _)| table)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(missing, vec!["widgets"]);
|
|
}
|
|
|
|
#[test]
|
|
fn migration_lint_detects_scoped_key_constraints_not_led_by_community_id() {
|
|
let sql = r#"
|
|
CREATE TABLE widgets (
|
|
community_id UUID NOT NULL,
|
|
id UUID PRIMARY KEY,
|
|
channel_id UUID REFERENCES channels(id),
|
|
slug TEXT,
|
|
CONSTRAINT widgets_name_unique UNIQUE (slug),
|
|
CONSTRAINT widgets_parent_fk FOREIGN KEY (channel_id) REFERENCES channels(id)
|
|
);
|
|
CREATE UNIQUE INDEX idx_widgets_slug ON widgets (slug);
|
|
ALTER TABLE widgets ADD CONSTRAINT widgets_alter_slug_unique UNIQUE (slug);
|
|
ALTER TABLE widgets ADD CONSTRAINT widgets_alter_parent_fk FOREIGN KEY (channel_id) REFERENCES channels(id);
|
|
CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL);
|
|
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
|
('_operator_global_tables', 'registry');
|
|
"#;
|
|
|
|
let violations = scoped_constraint_violations(sql);
|
|
|
|
assert!(violations
|
|
.iter()
|
|
.any(|violation| violation.kind == ConstraintKind::PrimaryKey));
|
|
assert_eq!(
|
|
violations
|
|
.iter()
|
|
.filter(|violation| violation.kind == ConstraintKind::ForeignKey)
|
|
.count(),
|
|
3
|
|
);
|
|
assert_eq!(
|
|
violations
|
|
.iter()
|
|
.filter(|violation| violation.kind == ConstraintKind::Unique)
|
|
.count(),
|
|
3
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn migration_lint_accepts_scoped_key_constraints_led_by_community_id() {
|
|
let sql = r#"
|
|
CREATE TABLE widgets (
|
|
community_id UUID NOT NULL,
|
|
id UUID NOT NULL,
|
|
channel_id UUID NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
PRIMARY KEY (community_id, id),
|
|
UNIQUE (community_id, slug),
|
|
FOREIGN KEY (community_id, channel_id) REFERENCES channels(community_id, id)
|
|
);
|
|
CREATE UNIQUE INDEX idx_widgets_slug ON widgets (community_id, slug);
|
|
ALTER TABLE widgets ADD CONSTRAINT widgets_alter_slug_unique UNIQUE (community_id, slug);
|
|
ALTER TABLE widgets ADD CONSTRAINT widgets_alter_parent_fk FOREIGN KEY (community_id, channel_id) REFERENCES channels(community_id, id);
|
|
CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL);
|
|
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
|
('_operator_global_tables', 'registry');
|
|
"#;
|
|
|
|
assert!(scoped_constraint_violations(sql).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn all_non_operator_global_tables_have_not_null_community_id() {
|
|
let sql = migration_sql();
|
|
let scoped = scoped_tables(sql);
|
|
let missing = create_table_definitions(sql)
|
|
.into_iter()
|
|
.filter(|(table, _)| scoped.contains(table))
|
|
.filter(|(_, definitions)| !table_has_not_null_community_id(definitions))
|
|
.map(|(table, _)| table)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
missing.is_empty(),
|
|
"every table not listed in _operator_global_tables must carry NOT NULL community_id; missing: {}",
|
|
missing.join(", ")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_primary_key_unique_and_foreign_key_constraints_lead_with_community_id() {
|
|
let sql = migration_sql();
|
|
let violations = scoped_constraint_violations(sql)
|
|
.into_iter()
|
|
.map(|constraint| {
|
|
format!(
|
|
"{}. {:?} constraint must lead with community_id: {}",
|
|
constraint.table, constraint.kind, constraint.description
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
violations.is_empty(),
|
|
"tenant-scoped tables are all tables not listed in _operator_global_tables; primary key, unique/FK constraints, and unique indexes on those tables must lead with community_id:\n{}",
|
|
violations.join("\n")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn channels_community_id_is_immutable_after_insert() {
|
|
let sql = migration_sql();
|
|
let forbidden_mutations = forbidden_channels_community_id_mutations(sql);
|
|
|
|
assert!(
|
|
forbidden_mutations.is_empty(),
|
|
"channels.community_id must not be re-tenanted after insert; forbidden migration statements:\n{}",
|
|
forbidden_mutations.join("\n---\n")
|
|
);
|
|
assert!(
|
|
has_channels_community_id_immutability_guard(sql),
|
|
"migrations define channels.community_id but no BEFORE UPDATE trigger/function guard that rejects OLD.community_id <> NEW.community_id was found"
|
|
);
|
|
}
|
|
|
|
async fn connect_test_pool() -> PgPool {
|
|
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
|
|
|
PgPool::connect(&database_url)
|
|
.await
|
|
.expect("connect to test DB")
|
|
}
|
|
|
|
async fn reset_public_schema(pool: &PgPool) {
|
|
sqlx::query("DROP SCHEMA IF EXISTS public CASCADE")
|
|
.execute(pool)
|
|
.await
|
|
.expect("drop public schema");
|
|
sqlx::query("CREATE SCHEMA IF NOT EXISTS public")
|
|
.execute(pool)
|
|
.await
|
|
.expect("create public schema");
|
|
}
|
|
|
|
async fn applied_versions(pool: &PgPool) -> Vec<i64> {
|
|
sqlx::query_scalar::<_, i64>(
|
|
"SELECT version FROM _sqlx_migrations WHERE success ORDER BY version",
|
|
)
|
|
.fetch_all(pool)
|
|
.await
|
|
.expect("read applied migrations")
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() {
|
|
let pool = connect_test_pool().await;
|
|
reset_public_schema(&pool).await;
|
|
|
|
run_migrations(&pool).await.expect("run migrations");
|
|
|
|
assert_eq!(applied_versions(&pool).await, vec![1]);
|
|
let tables = create_tables(migration_sql());
|
|
for table in [
|
|
"communities",
|
|
"events",
|
|
"channels",
|
|
"scheduled_workflow_fires",
|
|
"audit_log",
|
|
] {
|
|
let exists = sqlx::query_scalar::<_, bool>(
|
|
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)",
|
|
)
|
|
.bind(table)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap_or_else(|err| panic!("check table {table}: {err}"));
|
|
assert!(
|
|
tables.contains(table),
|
|
"migration parser should see {table}"
|
|
);
|
|
assert!(exists, "migration should create {table}");
|
|
}
|
|
}
|
|
}
|