From 91490cb406b034ae1b1efca20a2d619e42b2f44f Mon Sep 17 00:00:00 2001 From: D3SOX Date: Mon, 24 Aug 2026 12:13:29 +0200 Subject: [PATCH] fix(sqlite): preserve channel references during migration SQLite executes ON DELETE actions when the migration drops the channel table with foreign keys enabled. Pause enforcement around an explicit transactional rebuild, preserve channel rows on rollback, and cover both directions with the real embedded migration. --- .../2026-08-18-132346-0000_channel/down.sql | 13 +++- .../metadata.toml | 1 + .../2026-08-18-132346-0000_channel/up.sql | 13 +++- src/main.rs | 74 +++++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 migrations/sqlite/2026-08-18-132346-0000_channel/metadata.toml diff --git a/migrations/sqlite/2026-08-18-132346-0000_channel/down.sql b/migrations/sqlite/2026-08-18-132346-0000_channel/down.sql index c57599d..f387fe3 100644 --- a/migrations/sqlite/2026-08-18-132346-0000_channel/down.sql +++ b/migrations/sqlite/2026-08-18-132346-0000_channel/down.sql @@ -1,8 +1,17 @@ -DROP TABLE IF EXISTS channel; -CREATE TABLE channel +PRAGMA foreign_keys = OFF; +BEGIN; + +CREATE TABLE channel_temp ( id VARCHAR(24) PRIMARY KEY NOT NULL, name VARCHAR NOT NULL, avatar VARCHAR NOT NULL, verified BOOLEAN NOT NULL ); +INSERT INTO channel_temp +SELECT id, name, COALESCE(avatar, ''), verified FROM channel; +DROP TABLE channel; +ALTER TABLE channel_temp RENAME TO channel; + +COMMIT; +PRAGMA foreign_keys = ON; diff --git a/migrations/sqlite/2026-08-18-132346-0000_channel/metadata.toml b/migrations/sqlite/2026-08-18-132346-0000_channel/metadata.toml new file mode 100644 index 0000000..79e9221 --- /dev/null +++ b/migrations/sqlite/2026-08-18-132346-0000_channel/metadata.toml @@ -0,0 +1 @@ +run_in_transaction = false diff --git a/migrations/sqlite/2026-08-18-132346-0000_channel/up.sql b/migrations/sqlite/2026-08-18-132346-0000_channel/up.sql index a0836be..e1c0261 100644 --- a/migrations/sqlite/2026-08-18-132346-0000_channel/up.sql +++ b/migrations/sqlite/2026-08-18-132346-0000_channel/up.sql @@ -1,5 +1,11 @@ --- make avatar url nullable --- https://stackoverflow.com/questions/4007014/alter-column-in-sqlite +-- Rebuilding the parent table while foreign keys are enabled would execute the +-- ON DELETE actions and erase rows that reference channels. This migration runs +-- outside Diesel's transaction so the first PRAGMA takes effect, then wraps the +-- rebuild in its own transaction. +PRAGMA foreign_keys = OFF; +BEGIN; + +-- Make avatar nullable. CREATE TABLE channel_temp ( id VARCHAR(24) PRIMARY KEY NOT NULL, @@ -10,3 +16,6 @@ CREATE TABLE channel_temp INSERT INTO channel_temp SELECT * FROM channel; DROP TABLE channel; ALTER TABLE channel_temp RENAME TO channel; + +COMMIT; +PRAGMA foreign_keys = ON; diff --git a/src/main.rs b/src/main.rs index e3ac07a..c7f7ab1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -278,6 +278,36 @@ async fn run_migrations(pool: &DbPool, database_url: &str, approval: Option<&str mod tests { use super::require_migration_approval; + #[cfg(feature = "sqlite")] + #[derive(diesel::QueryableByName)] + struct RowCount { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + #[cfg(feature = "sqlite")] + fn table_count(conn: &mut diesel::SqliteConnection, table: &str) -> i64 { + use diesel::RunQueryDsl; + + diesel::sql_query(format!("SELECT COUNT(*) AS count FROM {table}")) + .get_result::(conn) + .unwrap() + .count + } + + #[cfg(feature = "sqlite")] + fn foreign_keys_enabled(conn: &mut diesel::SqliteConnection) -> bool { + use diesel::RunQueryDsl; + + diesel::sql_query( + "SELECT COUNT(*) AS count FROM pragma_foreign_keys WHERE foreign_keys = 1", + ) + .get_result::(conn) + .unwrap() + .count + == 1 + } + #[test] fn fresh_database_does_not_require_approval() { assert!(require_migration_approval(false, &["20260721".to_owned()], None).is_ok()); @@ -290,4 +320,48 @@ mod tests { assert!(require_migration_approval(true, &pending, Some("20260721")).is_err()); assert!(require_migration_approval(true, &pending, Some("20260721,20260722")).is_ok()); } + + #[cfg(feature = "sqlite")] + #[test] + fn channel_migration_preserves_referencing_rows() { + use diesel::Connection; + use diesel::connection::SimpleConnection; + use diesel_migrations::MigrationHarness; + + let mut conn = diesel::SqliteConnection::establish(":memory:").unwrap(); + conn.batch_execute("PRAGMA foreign_keys = ON;").unwrap(); + + let migrations = conn.pending_migrations(super::MIGRATIONS).unwrap(); + let channel_migration = migrations + .iter() + .position(|migration| migration.name().version().to_string() == "202608181323460000") + .expect("channel migration must be embedded"); + + conn.run_migrations(&migrations[..channel_migration]) + .unwrap(); + conn.batch_execute( + "INSERT INTO channel (id, name, avatar, verified) \ + VALUES ('channel-1', 'Channel', 'https://example.test/avatar', false); \ + INSERT INTO video (id, title, upload_date, uploader_id, thumbnail_url, duration) \ + VALUES ('video-1', 'Video', 0, 'channel-1', \ + 'https://example.test/thumbnail', 60);", + ) + .unwrap(); + + assert_eq!(table_count(&mut conn, "video"), 1); + conn.run_migration(migrations[channel_migration].as_ref()) + .unwrap(); + assert!(foreign_keys_enabled(&mut conn)); + assert_eq!(table_count(&mut conn, "channel"), 1); + assert_eq!(table_count(&mut conn, "video"), 1); + + conn.batch_execute("UPDATE channel SET avatar = NULL;") + .unwrap(); + conn.revert_migration(migrations[channel_migration].as_ref()) + .unwrap(); + assert!(foreign_keys_enabled(&mut conn)); + assert_eq!(table_count(&mut conn, "channel"), 1); + assert_eq!(table_count(&mut conn, "video"), 1); + assert_eq!(table_count(&mut conn, "pragma_foreign_key_check"), 0); + } }