pub mod runner;
pub fn is_online(version: u32) -> bool {
SOURCES
.iter()
.any(|s| s.version == version && s.sql.contains("{{concurrently}}"))
}
pub fn quote_identifier(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('"');
for c in name.chars() {
if c == '"' {
out.push('"');
}
out.push(c);
}
out.push('"');
out
}
#[derive(Debug, Clone, Copy)]
pub struct Placeholders<'a> {
pub schema: &'a str,
pub concurrently: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderError {
Unknown {
name: String,
offset: usize,
},
Unterminated {
offset: usize,
},
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RenderError::Unknown { name, offset } => {
write!(f, "unknown placeholder `{{{{{name}}}}}` at byte {offset}")
}
RenderError::Unterminated { offset } => {
write!(f, "unterminated placeholder at byte {offset}")
}
}
}
}
impl std::error::Error for RenderError {}
pub fn render(sql: &str, values: Placeholders<'_>) -> Result<String, RenderError> {
let mut out = String::with_capacity(sql.len() + 64);
let mut rest = sql;
let mut consumed = 0usize;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
let Some(close) = after.find("}}") else {
return Err(RenderError::Unterminated {
offset: consumed + open,
});
};
let name = &after[..close];
let value = match name {
"schema" => values.schema,
"concurrently" => values.concurrently,
_ => {
return Err(RenderError::Unknown {
name: name.to_owned(),
offset: consumed + open,
});
}
};
out.push_str(value);
consumed += open + 2 + close + 2;
rest = &after[close + 2..];
}
out.push_str(rest);
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Applies {
Always,
WhenNotify,
Postgres,
Cockroach,
}
impl Applies {
fn matches(self, dialect: Dialect, notify: bool) -> bool {
match self {
Applies::Always => true,
Applies::WhenNotify => notify,
Applies::Postgres => !dialect.is_cockroach(),
Applies::Cockroach => dialect.is_cockroach(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MigrationSource {
pub version: u32,
pub name: &'static str,
pub sql: &'static str,
pub applies: Applies,
}
macro_rules! sources {
($(($version:expr, $file:literal, $applies:expr)),* $(,)?) => {
pub const SOURCES: &[MigrationSource] = &[
$(MigrationSource {
version: $version,
name: $file,
sql: include_str!(concat!("../../migrations/", $file)),
applies: $applies,
}),*
];
};
}
sources![
(1, "1_initial_dbos_schema.sql", Applies::Always),
(
1,
"1_initial_dbos_schema_listen_notify.sql",
Applies::WhenNotify
),
(2, "2_add_queue_partition_key.sql", Applies::Always),
(3, "3_add_workflow_status_index.sql", Applies::Always),
(4, "4_add_forked_from.sql", Applies::Always),
(5, "5_add_step_timestamps.sql", Applies::Always),
(6, "6_add_workflow_events_history.sql", Applies::Always),
(7, "7_add_owner_xid.sql", Applies::Always),
(8, "8_add_parent_workflow_id.sql", Applies::Always),
(9, "9_add_workflow_schedules.sql", Applies::Always),
(10, "10_add_notifications_pkey.sql", Applies::Always),
(11, "11_add_serialization_columns.sql", Applies::Always),
(12, "12_add_notifications_consumed.sql", Applies::Always),
(13, "13_add_application_versions.sql", Applies::Always),
(14, "14_add_pgsql_client_functions.sql", Applies::Always),
(15, "15_add_workflow_schedule_columns.sql", Applies::Always),
(16, "16_add_delay_until.sql", Applies::Always),
(
17,
"17_add_workflow_schedule_queue_name.sql",
Applies::Always
),
(18, "18_add_was_forked_from.sql", Applies::Always),
(
19,
"19_add_operation_outputs_completed_at_index.sql",
Applies::Always
),
(20, "20_set_function_search_path.sql", Applies::Postgres),
(
20,
"20_set_notify_function_search_path.sql",
Applies::WhenNotify
),
(21, "21_create_queues_table.sql", Applies::Always),
(22, "22_drop_forked_from_index.sql", Applies::Always),
(
23,
"23_create_partial_forked_from_index.sql",
Applies::Always
),
(24, "24_drop_parent_workflow_id_index.sql", Applies::Always),
(
25,
"25_create_partial_parent_workflow_id_index.sql",
Applies::Always
),
(26, "26_drop_executor_id_index.sql", Applies::Always),
(27, "27_create_partial_dedup_id_index.sql", Applies::Always),
(28, "28_drop_dedup_id_constraint.sql", Applies::Postgres),
(
28,
"28_drop_dedup_id_constraint_cockroach.sql",
Applies::Cockroach
),
(29, "29_create_pending_index.sql", Applies::Always),
(30, "30_create_failed_index.sql", Applies::Always),
(31, "31_drop_status_index.sql", Applies::Always),
(32, "32_create_in_flight_index.sql", Applies::Always),
(33, "33_add_rate_limited.sql", Applies::Always),
(34, "34_create_rate_limited_index.sql", Applies::Always),
(
35,
"35_drop_queue_status_started_index.sql",
Applies::Always
),
(36, "36_add_completed_at.sql", Applies::Always),
(37, "37_create_started_at_index.sql", Applies::Always),
(38, "38_update_enqueue_workflow.sql", Applies::Always),
(
38,
"38_set_enqueue_workflow_search_path.sql",
Applies::Postgres
),
(39, "39_create_streams_trigger.sql", Applies::WhenNotify),
(40, "40_add_attributes.sql", Applies::Always),
(41, "41_add_schedule_name.sql", Applies::Always),
(42, "42_add_debounce_columns.sql", Applies::Always),
(43, "43_drop_streams_trigger.sql", Applies::WhenNotify),
(
44,
"44_drop_workflow_events_trigger.sql",
Applies::WhenNotify
),
(45, "45_add_partition_dequeue_index.sql", Applies::Always),
(46, "46_add_partition_dequeue_index_v2.sql", Applies::Always),
(47, "47_drop_partition_dequeue_index.sql", Applies::Always),
(
100,
"100_workflow_status_application_name.sql",
Applies::Always
),
(101, "101_queues_application_name.sql", Applies::Always),
(
102,
"102_workflow_schedules_application_name.sql",
Applies::Always
),
(
103,
"103_application_versions_application_name.sql",
Applies::Always
),
(
104,
"104_operation_outputs_application_name.sql",
Applies::Always
),
(
105,
"105_enqueue_workflow_application_name.sql",
Applies::Always
),
(
105,
"105_set_enqueue_workflow_search_path.sql",
Applies::Postgres
),
(
106,
"106_application_versions_owner_key.sql",
Applies::Always
),
(
107,
"107_application_versions_unclaimed_key.sql",
Applies::Always
),
(108, "108_add_queue_partition_limits.sql", Applies::Always),
];
pub const MIGRATION_10_PK_PROBE: MigrationSource = MigrationSource {
version: 10,
name: "10_check_notifications_pkey.sql",
sql: include_str!("../../migrations/10_check_notifications_pkey.sql"),
applies: Applies::Always,
};
pub fn source(name: &str) -> Option<&'static MigrationSource> {
SOURCES.iter().find(|s| s.name == name)
}
pub const LOCAL_MIGRATIONS: u32 = 47;
pub const SHARED_MIGRATION_BASE: u32 = 100;
pub const SHARED_MIGRATIONS: u32 = 108;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dialect {
Postgres,
Cockroach,
}
impl Dialect {
fn concurrently(self) -> &'static str {
match self {
Dialect::Postgres => "CONCURRENTLY",
Dialect::Cockroach => "",
}
}
fn is_cockroach(self) -> bool {
matches!(self, Dialect::Cockroach)
}
}
#[derive(Debug, Clone)]
pub struct Migration {
pub version: u32,
pub sql: String,
pub online: bool,
pub guard: Option<&'static str>,
}
pub fn build_migrations(schema: &str, dialect: Dialect, use_listen_notify: bool) -> Vec<Migration> {
let quoted = quote_identifier(schema);
let notify = use_listen_notify && !dialect.is_cockroach();
let values = Placeholders {
schema: "ed,
concurrently: dialect.concurrently(),
};
(1..=SHARED_MIGRATIONS)
.map(|version| {
let sql = SOURCES
.iter()
.filter(|s| s.version == version && s.applies.matches(dialect, notify))
.map(|s| render(s.sql, values).expect("corpus renders"))
.collect::<Vec<_>>()
.join("\n");
Migration {
version,
online: is_online(version) && !dialect.is_cockroach(),
guard: (version == 10).then_some(MIGRATION_10_PK_PROBE.sql),
sql,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const SCHEMA: &str = r#""dbos""#;
const VALUES: Placeholders<'_> = Placeholders {
schema: SCHEMA,
concurrently: "CONCURRENTLY",
};
#[test]
fn a_version_is_the_files_that_apply_to_it() {
let built = |dialect, notify| {
build_migrations("dbos", dialect, notify)
.into_iter()
.map(|m| (m.version, m.sql))
.collect::<std::collections::HashMap<_, _>>()
};
let pg_notify = built(Dialect::Postgres, true);
let pg_plain = built(Dialect::Postgres, false);
let crdb = built(Dialect::Cockroach, true);
assert!(pg_notify[&1].contains("TRIGGER"));
assert!(!pg_plain[&1].contains("TRIGGER"));
assert!(!crdb[&1].contains("TRIGGER"));
assert!(crdb[&20].is_empty());
assert!(pg_notify[&20].len() > pg_plain[&20].len());
assert!(pg_notify[&28].contains("DROP CONSTRAINT"));
assert_ne!(pg_notify[&28], crdb[&28]);
assert!(!crdb[&28].is_empty());
for version in [38, 105] {
assert!(pg_plain[&version].contains("ALTER FUNCTION"));
assert!(!crdb[&version].contains("ALTER FUNCTION"));
assert!(
crdb[&version].contains("CREATE OR REPLACE FUNCTION"),
"{version} still defines the function on CockroachDB",
);
}
for version in [39, 43, 44] {
assert!(
!pg_notify[&version].is_empty(),
"{version} applies with notify"
);
assert!(
pg_plain[&version].is_empty(),
"{version} is a no-op without"
);
}
let migrations = build_migrations("dbos", Dialect::Postgres, true);
let guarded: Vec<u32> = migrations
.iter()
.filter(|m| m.guard.is_some())
.map(|m| m.version)
.collect();
assert_eq!(guarded, [10]);
}
#[test]
fn the_gap_to_the_shared_base_is_padding() {
let migrations = build_migrations("dbos", Dialect::Postgres, true);
assert_eq!(migrations.len() as u32, SHARED_MIGRATIONS);
assert_eq!(
migrations.last().expect("non-empty").version,
SHARED_MIGRATIONS
);
for m in &migrations {
let padding = m.version > LOCAL_MIGRATIONS && m.version < SHARED_MIGRATION_BASE;
if padding {
assert!(
m.sql.is_empty(),
"migration {} is padding and must do nothing",
m.version
);
}
}
for version in SHARED_MIGRATION_BASE..=SHARED_MIGRATIONS {
let m = &migrations[version as usize - 1];
assert_eq!(m.version, version);
assert!(
m.sql.contains("ALTER TABLE") || m.sql.contains("CREATE"),
"migration {version} should carry the shared series' work",
);
}
}
#[test]
fn corpus_matches_upstream_shape() {
assert_eq!(SOURCES.len(), 61);
let mut versions: Vec<u32> = SOURCES.iter().map(|s| s.version).collect();
versions.sort_unstable();
versions.dedup();
let expected: Vec<u32> = (1..=LOCAL_MIGRATIONS)
.chain(SHARED_MIGRATION_BASE..=SHARED_MIGRATIONS)
.collect();
assert_eq!(
versions, expected,
"the corpus should cover its own history and the shared series, and nothing between",
);
assert_eq!(
SOURCES.len() - versions.len(),
5,
"expected 5 variant files"
);
}
#[test]
fn every_file_is_non_empty_and_named_for_its_version() {
for s in SOURCES {
assert!(!s.sql.trim().is_empty(), "{} is empty", s.name);
let prefix = s
.name
.split('_')
.next()
.and_then(|p| p.parse::<u32>().ok())
.unwrap_or_else(|| panic!("{} has no numeric prefix", s.name));
assert_eq!(
prefix, s.version,
"{} is filed under the wrong version",
s.name
);
}
}
#[test]
fn headers_name_the_migration_their_filename_claims() {
let mut checked = 0;
for s in SOURCES
.iter()
.chain(std::iter::once(&MIGRATION_10_PK_PROBE))
{
let header = s.sql.lines().next().unwrap_or_default();
let Some(claimed) = header
.split_whitespace()
.skip_while(|w| !w.eq_ignore_ascii_case("migration"))
.nth(1)
.and_then(|w| w.trim_end_matches(':').parse::<u32>().ok())
else {
continue;
};
assert_eq!(
claimed, s.version,
"{}: header says migration {claimed}",
s.name,
);
checked += 1;
}
assert_eq!(checked, 60, "expected 60 files to carry a numbered header");
}
#[test]
fn online_migrations_match_the_reference_set() {
let online: Vec<u32> = (1..=SHARED_MIGRATIONS).filter(|v| is_online(*v)).collect();
assert_eq!(
online,
[
22, 23, 24, 25, 26, 27, 29, 30, 31, 32, 34, 35, 37, 45, 46, 47, 107
],
);
assert!(!is_online(106) && is_online(107));
assert!(!is_online(28) && !is_online(33) && !is_online(36));
}
#[test]
fn concurrently_arrives_only_through_the_placeholder() {
for s in SOURCES {
let statements: String = s
.sql
.lines()
.map(|line| line.split("--").next().unwrap_or(""))
.collect::<Vec<_>>()
.join("\n");
assert!(
!statements
.replace("{{concurrently}}", "")
.contains("CONCURRENTLY"),
"{}: CONCURRENTLY is hardcoded; it must come from the placeholder",
s.name,
);
}
}
#[test]
fn every_file_renders_completely() {
for s in SOURCES
.iter()
.chain(std::iter::once(&MIGRATION_10_PK_PROBE))
{
let rendered = render(s.sql, VALUES)
.unwrap_or_else(|e| panic!("{} failed to render: {e}", s.name));
assert!(
!rendered.contains("{{"),
"{}: unrendered placeholder",
s.name
);
}
}
#[test]
fn plpgsql_format_strings_pass_through() {
for name in [
"14_add_pgsql_client_functions.sql",
"38_update_enqueue_workflow.sql",
"105_enqueue_workflow_application_name.sql",
] {
let s = source(name).unwrap();
assert!(
s.sql.contains("Workflow %s with queue %s"),
"{name}: source"
);
let rendered = render(s.sql, VALUES).unwrap();
assert!(
rendered.contains("Workflow %s with queue %s"),
"{name}: renderer consumed a plpgsql placeholder",
);
}
}
#[test]
fn single_braces_are_literal() {
assert_eq!(
render("DEFAULT '{}'::JSON, x {{schema}}", VALUES).unwrap(),
r#"DEFAULT '{}'::JSON, x "dbos""#,
);
for name in [
"14_add_pgsql_client_functions.sql",
"38_update_enqueue_workflow.sql",
"105_enqueue_workflow_application_name.sql",
] {
let rendered = render(source(name).unwrap().sql, VALUES).unwrap();
assert!(
rendered.contains("'{}'::JSON"),
"{name}: JSON default mangled"
);
}
}
#[test]
fn the_migration_ten_probe_binds_its_schema() {
assert_eq!(MIGRATION_10_PK_PROBE.version, 10);
assert!(
!SOURCES.iter().any(|s| s.name == MIGRATION_10_PK_PROBE.name),
"the probe must not be in the set the runner applies",
);
assert!(
!MIGRATION_10_PK_PROBE.sql.contains("%s"),
"a probe has no interpolation slots",
);
assert!(
MIGRATION_10_PK_PROBE.sql.contains("$1"),
"the probe takes the schema as a bind parameter",
);
}
#[test]
fn no_migration_declares_integer() {
for s in SOURCES
.iter()
.chain(std::iter::once(&MIGRATION_10_PK_PROBE))
{
let sql = s
.sql
.lines()
.filter(|l| !l.trim_start().starts_with("--"))
.collect::<String>()
.to_ascii_uppercase();
assert!(
!sql.contains("INTEGER"),
"{}: declares INTEGER; use INT4, which is 32 bits on both backends",
s.name,
);
}
}
#[test]
fn every_placeholder_is_known() {
for s in SOURCES
.iter()
.chain(std::iter::once(&MIGRATION_10_PK_PROBE))
{
for frag in s.sql.split("{{").skip(1) {
let name = frag.split("}}").next().unwrap_or_default();
assert!(
matches!(name, "schema" | "concurrently"),
"{}: unknown placeholder {{{{{name}}}}}",
s.name,
);
}
}
}
#[test]
fn unknown_and_unterminated_placeholders_are_rejected() {
assert_eq!(
render("SELECT {{nope}}", VALUES),
Err(RenderError::Unknown {
name: "nope".to_owned(),
offset: 7,
}),
);
assert_eq!(
render("SELECT {{schema", VALUES),
Err(RenderError::Unterminated { offset: 7 }),
);
}
#[test]
fn concurrently_renders_empty_for_cockroach() {
let s = source("23_create_partial_forked_from_index.sql").unwrap();
let crdb = render(
s.sql,
Placeholders {
schema: SCHEMA,
concurrently: "",
},
)
.unwrap();
assert!(!crdb.contains("CONCURRENTLY"));
assert!(
render(s.sql, VALUES)
.unwrap()
.contains("CREATE INDEX CONCURRENTLY")
);
}
#[test]
fn quoting_handles_embedded_quotes() {
assert_eq!(quote_identifier("dbos"), r#""dbos""#);
assert_eq!(quote_identifier(r#"a"b"#), r#""a""b""#);
}
#[test]
fn dollar_quoted_files_contain_semicolons_inside_their_blocks() {
let expected = [
"1_initial_dbos_schema_listen_notify.sql",
"14_add_pgsql_client_functions.sql",
"38_update_enqueue_workflow.sql",
"39_create_streams_trigger.sql",
"105_enqueue_workflow_application_name.sql",
];
let found: Vec<&str> = SOURCES
.iter()
.filter(|s| s.sql.contains("$$"))
.map(|s| s.name)
.collect();
assert_eq!(found.len(), expected.len());
for name in expected {
assert!(found.contains(&name), "{name} should carry a $$ block");
let sql = source(name).unwrap().sql;
let inside: String = sql.split("$$").skip(1).step_by(2).collect();
assert!(
inside.contains(';'),
"{name}: expected semicolons inside its $$ block",
);
}
}
}