refactor: move datatable migration rename/delete cascade into module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-07-02 15:48:41 +02:00
parent cdfd7b3d61
commit 74a7c843e7
2 changed files with 124 additions and 124 deletions
@@ -1303,6 +1303,60 @@ pub(crate) async fn compare_two_datatable_migration(
})
}
#[derive(Deserialize, Debug)]
pub(crate) struct DatatableRename {
pub(crate) from: String,
pub(crate) to: String,
}
/// Keep `datatable_migrations` in sync when data tables are renamed or deleted
/// in the workspace config.
///
/// Renames are applied in two phases through a temporary key so that a rename
/// chain or a swap (A->B, B->A) can't transiently collide on the
/// (workspace_id, datatable, timestamp) primary key mid-update.
pub(crate) async fn cascade_datatable_migration_renames_and_deletes(
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
renames: &[DatatableRename],
deleted_datatables: &[String],
) -> Result<()> {
if !deleted_datatables.is_empty() {
sqlx::query!(
"DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = ANY($2::text[])",
w_id,
deleted_datatables
)
.execute(&mut **tx)
.await?;
}
for (i, r) in renames.iter().enumerate() {
let tmp = format!("__wm_rename_tmp/{i}");
sqlx::query!(
"UPDATE datatable_migrations SET datatable = $3 WHERE workspace_id = $1 AND datatable = $2",
w_id,
&r.from,
&tmp
)
.execute(&mut **tx)
.await?;
}
for (i, r) in renames.iter().enumerate() {
let tmp = format!("__wm_rename_tmp/{i}");
sqlx::query!(
"UPDATE datatable_migrations SET datatable = $3 WHERE workspace_id = $1 AND datatable = $2",
w_id,
&tmp,
&r.to
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1382,4 +1436,72 @@ mod tests {
// empty filename
assert_eq!(parse_datatable_migration_diff_path("mydt/"), None);
}
async fn seed_migration(pool: &DB, w_id: &str, datatable: &str, timestamp: i64, name: &str) {
sqlx::query(
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up) \
VALUES ($1, $2, $3, $4, 'select 1;')",
)
.bind(w_id)
.bind(datatable)
.bind(timestamp)
.bind(name)
.execute(pool)
.await
.unwrap();
}
async fn migration_keys(pool: &DB, w_id: &str) -> Vec<(String, String)> {
sqlx::query_as::<_, (String, String)>(
"SELECT datatable, name FROM datatable_migrations WHERE workspace_id = $1 \
ORDER BY datatable, timestamp",
)
.bind(w_id)
.fetch_all(pool)
.await
.unwrap()
}
// The cascade keeps each data table's migrations attached to its name when a
// data table is renamed, drops them when it is deleted, and survives a swap
// (A->B, B->A) at a shared timestamp without a primary-key collision.
#[sqlx::test(migrations = "../migrations")]
async fn cascade_renames_and_deletes_datatable_migrations(pool: DB) {
let w_id = format!("dtmig{}", uuid::Uuid::new_v4().simple());
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test@windmill.dev')")
.bind(&w_id)
.execute(&pool)
.await
.unwrap();
// rename a -> a2, delete d, and swap sa <-> sb (both at timestamp 5000)
seed_migration(&pool, &w_id, "a", 1, "a_mig").await;
seed_migration(&pool, &w_id, "d", 1, "d_mig").await;
seed_migration(&pool, &w_id, "sa", 5000, "sa_mig").await;
seed_migration(&pool, &w_id, "sb", 5000, "sb_mig").await;
let mut tx = pool.begin().await.unwrap();
cascade_datatable_migration_renames_and_deletes(
&mut tx,
&w_id,
&[
DatatableRename { from: "a".to_string(), to: "a2".to_string() },
DatatableRename { from: "sa".to_string(), to: "sb".to_string() },
DatatableRename { from: "sb".to_string(), to: "sa".to_string() },
],
&["d".to_string()],
)
.await
.unwrap();
tx.commit().await.unwrap();
assert_eq!(
migration_keys(&pool, &w_id).await,
vec![
("a2".to_string(), "a_mig".to_string()),
("sa".to_string(), "sb_mig".to_string()),
("sb".to_string(), "sa_mig".to_string()),
]
);
}
}
@@ -415,19 +415,13 @@ pub struct DucklakeSettings {
pub ducklakes: HashMap<String, Ducklake>,
}
#[derive(Deserialize, Debug)]
struct DatatableRename {
from: String,
to: String,
}
#[derive(Deserialize, Debug)]
struct EditDataTableConfig {
settings: DataTableSettings,
// Data table renames (old -> new) and deletions, tracked client-side by a
// stable id, so we can cascade or drop each data table's migrations.
#[serde(default)]
renames: Vec<DatatableRename>,
renames: Vec<crate::datatable_migrations::DatatableRename>,
#[serde(default)]
deleted_datatables: Vec<String>,
}
@@ -1852,74 +1846,6 @@ mod tests {
format!("text={}...", "é".repeat(27))
);
}
async fn seed_migration(pool: &DB, w_id: &str, datatable: &str, timestamp: i64, name: &str) {
sqlx::query(
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up) \
VALUES ($1, $2, $3, $4, 'select 1;')",
)
.bind(w_id)
.bind(datatable)
.bind(timestamp)
.bind(name)
.execute(pool)
.await
.unwrap();
}
async fn migration_keys(pool: &DB, w_id: &str) -> Vec<(String, String)> {
sqlx::query_as::<_, (String, String)>(
"SELECT datatable, name FROM datatable_migrations WHERE workspace_id = $1 \
ORDER BY datatable, timestamp",
)
.bind(w_id)
.fetch_all(pool)
.await
.unwrap()
}
// The cascade keeps each data table's migrations attached to its name when a
// data table is renamed, drops them when it is deleted, and survives a swap
// (A->B, B->A) at a shared timestamp without a primary-key collision.
#[sqlx::test(migrations = "../migrations")]
async fn cascade_renames_and_deletes_datatable_migrations(pool: DB) {
let w_id = format!("dtmig{}", uuid::Uuid::new_v4().simple());
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test@windmill.dev')")
.bind(&w_id)
.execute(&pool)
.await
.unwrap();
// rename a -> a2, delete d, and swap sa <-> sb (both at timestamp 5000)
seed_migration(&pool, &w_id, "a", 1, "a_mig").await;
seed_migration(&pool, &w_id, "d", 1, "d_mig").await;
seed_migration(&pool, &w_id, "sa", 5000, "sa_mig").await;
seed_migration(&pool, &w_id, "sb", 5000, "sb_mig").await;
let mut tx = pool.begin().await.unwrap();
cascade_datatable_migration_renames_and_deletes(
&mut tx,
&w_id,
&[
DatatableRename { from: "a".to_string(), to: "a2".to_string() },
DatatableRename { from: "sa".to_string(), to: "sb".to_string() },
DatatableRename { from: "sb".to_string(), to: "sa".to_string() },
],
&["d".to_string()],
)
.await
.unwrap();
tx.commit().await.unwrap();
assert_eq!(
migration_keys(&pool, &w_id).await,
vec![
("a2".to_string(), "a_mig".to_string()),
("sa".to_string(), "sb_mig".to_string()),
("sb".to_string(), "sa_mig".to_string()),
]
);
}
}
/// Resolve a source string to PgDatabase credentials with user-scoped permission checks.
@@ -2486,7 +2412,7 @@ async fn edit_datatable_config(
.execute(&mut *tx)
.await?;
cascade_datatable_migration_renames_and_deletes(
crate::datatable_migrations::cascade_datatable_migration_renames_and_deletes(
&mut tx,
&w_id,
&new_config.renames,
@@ -2499,54 +2425,6 @@ async fn edit_datatable_config(
Ok(format!("Edit datatable config for workspace {}", &w_id))
}
/// Keep `datatable_migrations` in sync when data tables are renamed or deleted
/// in the workspace config.
///
/// Renames are applied in two phases through a temporary key so that a rename
/// chain or a swap (A->B, B->A) can't transiently collide on the
/// (workspace_id, datatable, timestamp) primary key mid-update.
async fn cascade_datatable_migration_renames_and_deletes(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
renames: &[DatatableRename],
deleted_datatables: &[String],
) -> Result<()> {
if !deleted_datatables.is_empty() {
sqlx::query!(
"DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = ANY($2::text[])",
w_id,
deleted_datatables
)
.execute(&mut **tx)
.await?;
}
for (i, r) in renames.iter().enumerate() {
let tmp = format!("__wm_rename_tmp/{i}");
sqlx::query!(
"UPDATE datatable_migrations SET datatable = $3 WHERE workspace_id = $1 AND datatable = $2",
w_id,
&r.from,
&tmp
)
.execute(&mut **tx)
.await?;
}
for (i, r) in renames.iter().enumerate() {
let tmp = format!("__wm_rename_tmp/{i}");
sqlx::query!(
"UPDATE datatable_migrations SET datatable = $3 WHERE workspace_id = $1 AND datatable = $2",
w_id,
&tmp,
&r.to
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
#[derive(Deserialize)]
pub struct EditGitSyncConfig {
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,