From 913165db032e3daf722e7927f2c915747b08227a Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 23 Jul 2026 17:52:04 +0100 Subject: [PATCH 1/3] Add read-only migration status queries that skip CREATE TABLE Add `get_pending_migrations_read_only`, `get_applied_migrations_read_only`, and `get_migration_with_status_read_only` to both `MigratorTrait` and `MigratorTraitSelf`. They mirror the existing query methods but never run `CREATE TABLE`, so a database user without DDL privileges can check migration status. A missing migration table reports all migrations as pending. Purely additive; existing methods are unchanged. Closes #3141 --- CHANGELOG.md | 3 + sea-orm-migration/src/migrator.rs | 46 ++++++++++++ sea-orm-migration/src/migrator/exec.rs | 16 ++++ sea-orm-migration/src/migrator/with_self.rs | 69 +++++++++++++++++ sea-orm-migration/tests/read_only.rs | 82 +++++++++++++++++++++ 5 files changed, 216 insertions(+) create mode 100644 sea-orm-migration/tests/read_only.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e875790b0..a84e15f1a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `ConnectOptions::test_before_acquire_if_idle_for(Duration)` — ping a pooled connection before it is handed out only once it has been idle for at least the given duration, instead of on every acquire (`test_before_acquire`). Setting it disables `test_before_acquire`. - `ConnectOptions::map_sqlx_postgres_before_acquire` / `map_sqlx_mysql_before_acquire` / `map_sqlx_sqlite_before_acquire` — install a per-backend SQLx `before_acquire` callback. Composes with the idle-ping shorthand above: the idle-ping runs first, then the callback. - `ConnectOptions::get_test_before_acquire` / `get_test_before_acquire_if_idle_for` getters. +- `MigratorTrait::get_pending_migrations_read_only` / `get_applied_migrations_read_only` / `get_migration_with_status_read_only` (and the `with-self` equivalents) — query migration status without running `CREATE TABLE`, so a database user without DDL privileges can check pending migrations. If the migration table does not exist, all migrations are reported as pending. ([#3141]) + +[#3141]: https://github.com/SeaQL/sea-orm/discussions/3141 ## 2.0.0 - 2026-07-19 diff --git a/sea-orm-migration/src/migrator.rs b/sea-orm-migration/src/migrator.rs index bdae6073d0..389ad7e8a6 100644 --- a/sea-orm-migration/src/migrator.rs +++ b/sea-orm-migration/src/migrator.rs @@ -118,6 +118,52 @@ pub trait MigratorTrait: Send { .collect()) } + /// Get list of migrations with status, without creating the migration table. + /// + /// Unlike [`get_migration_with_status`](Self::get_migration_with_status), this never runs + /// `CREATE TABLE`, so it can be called by a database user without DDL privileges (for + /// example, checking pending migrations from a read-only connection). If the migration + /// table does not exist, every migration is reported as [`MigrationStatus::Pending`]. + async fn get_migration_with_status_read_only(db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + get_migration_with_status( + Self::get_migration_files(), + get_migration_models_read_only(db, Self::migration_table_name()).await?, + ) + } + + /// Get list of pending migrations without creating the migration table. + /// + /// The read-only counterpart of [`get_pending_migrations`](Self::get_pending_migrations); + /// see [`get_migration_with_status_read_only`](Self::get_migration_with_status_read_only). + async fn get_pending_migrations_read_only(db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + Ok(Self::get_migration_with_status_read_only(db) + .await? + .into_iter() + .filter(|file| file.status == MigrationStatus::Pending) + .collect()) + } + + /// Get list of applied migrations without creating the migration table. + /// + /// The read-only counterpart of [`get_applied_migrations`](Self::get_applied_migrations); + /// see [`get_migration_with_status_read_only`](Self::get_migration_with_status_read_only). + async fn get_applied_migrations_read_only(db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + Ok(Self::get_migration_with_status_read_only(db) + .await? + .into_iter() + .filter(|file| file.status == MigrationStatus::Applied) + .collect()) + } + /// Create migration table `seaql_migrations` in the database async fn install(db: &C) -> Result<(), DbErr> where diff --git a/sea-orm-migration/src/migrator/exec.rs b/sea-orm-migration/src/migrator/exec.rs index daaaeadc0b..a02753d04d 100644 --- a/sea-orm-migration/src/migrator/exec.rs +++ b/sea-orm-migration/src/migrator/exec.rs @@ -33,6 +33,22 @@ where .collect() } +pub async fn get_migration_models_read_only( + db: &C, + migration_table_name: DynIden, +) -> Result, DbErr> +where + C: ConnectionTrait, +{ + // Unlike `get_migration_models`, this never issues `CREATE TABLE` (see `install`), so it + // works for a database user without DDL privileges. Existence is probed with a read-only + // information-schema query; a missing table means nothing has been recorded yet. + if !crate::manager::has_table(db, migration_table_name.to_string()).await? { + return Ok(Vec::new()); + } + get_migration_models(db, migration_table_name).await +} + pub fn get_migration_with_status( migration_files: Vec, migration_models: Vec, diff --git a/sea-orm-migration/src/migrator/with_self.rs b/sea-orm-migration/src/migrator/with_self.rs index d679bb0f85..d92fd084ed 100644 --- a/sea-orm-migration/src/migrator/with_self.rs +++ b/sea-orm-migration/src/migrator/with_self.rs @@ -76,6 +76,54 @@ pub trait MigratorTraitSelf: Sized + Send + Sync { .collect()) } + /// Get list of migrations with status, without creating the migration table. + /// + /// Unlike [`get_migration_with_status`](Self::get_migration_with_status), this never runs + /// `CREATE TABLE`, so it can be called by a database user without DDL privileges (for + /// example, checking pending migrations from a read-only connection). If the migration + /// table does not exist, every migration is reported as [`MigrationStatus::Pending`]. + async fn get_migration_with_status_read_only(&self, db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + get_migration_with_status( + self.get_migration_files(), + get_migration_models_read_only(db, self.migration_table_name()).await?, + ) + } + + /// Get list of pending migrations without creating the migration table. + /// + /// The read-only counterpart of [`get_pending_migrations`](Self::get_pending_migrations); + /// see [`get_migration_with_status_read_only`](Self::get_migration_with_status_read_only). + async fn get_pending_migrations_read_only(&self, db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + Ok(self + .get_migration_with_status_read_only(db) + .await? + .into_iter() + .filter(|file| file.status == MigrationStatus::Pending) + .collect()) + } + + /// Get list of applied migrations without creating the migration table. + /// + /// The read-only counterpart of [`get_applied_migrations`](Self::get_applied_migrations); + /// see [`get_migration_with_status_read_only`](Self::get_migration_with_status_read_only). + async fn get_applied_migrations_read_only(&self, db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + Ok(self + .get_migration_with_status_read_only(db) + .await? + .into_iter() + .filter(|file| file.status == MigrationStatus::Applied) + .collect()) + } + /// Create migration table `seaql_migrations` in the database async fn install(&self, db: &C) -> Result<(), DbErr> where @@ -202,6 +250,27 @@ where M::get_pending_migrations(db).await } + async fn get_migration_with_status_read_only(&self, db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + M::get_migration_with_status_read_only(db).await + } + + async fn get_pending_migrations_read_only(&self, db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + M::get_pending_migrations_read_only(db).await + } + + async fn get_applied_migrations_read_only(&self, db: &C) -> Result, DbErr> + where + C: ConnectionTrait, + { + M::get_applied_migrations_read_only(db).await + } + async fn get_applied_migrations(&self, db: &C) -> Result, DbErr> where C: ConnectionTrait, diff --git a/sea-orm-migration/tests/read_only.rs b/sea-orm-migration/tests/read_only.rs new file mode 100644 index 0000000000..bc92736042 --- /dev/null +++ b/sea-orm-migration/tests/read_only.rs @@ -0,0 +1,82 @@ +//! Self-contained (in-memory SQLite) coverage for the read-only migration-status API added +//! for discussion #3141: querying migration status must not create the `seaql_migrations` +//! table, so a database user without DDL privileges can use it. +//! +//! Run: cargo test --features sqlx-sqlite,runtime-tokio-rustls --test read_only + +use sea_orm::Database; +use sea_orm_migration::{MigratorTraitSelf, async_trait, migrator::MigrationStatus, prelude::*}; + +struct M1; +impl MigrationName for M1 { + fn name(&self) -> &str { + "m000001_first" + } +} +#[async_trait::async_trait] +impl MigrationTrait for M1 { + async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} + +struct M2; +impl MigrationName for M2 { + fn name(&self) -> &str { + "m000002_second" + } +} +#[async_trait::async_trait] +impl MigrationTrait for M2 { + async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} + +struct Migrator; +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![Box::new(M1), Box::new(M2)] + } +} + +#[tokio::test] +async fn read_only_status_does_not_create_table() -> Result<(), DbErr> { + let db = Database::connect("sqlite::memory:").await?; + let manager = SchemaManager::new(&db); + + // On a fresh database the read-only queries report every migration as pending... + // (`Migrator` implements both `MigratorTrait` and `MigratorTraitSelf`, so the static + // calls are fully qualified to disambiguate — the same applies to existing methods.) + let pending = ::get_pending_migrations_read_only(&db).await?; + assert_eq!(pending.len(), 2); + assert!( + ::get_applied_migrations_read_only(&db) + .await? + .is_empty() + ); + let all = ::get_migration_with_status_read_only(&db).await?; + assert_eq!(all.len(), 2); + assert!(all.iter().all(|m| m.status() == MigrationStatus::Pending)); + + // ...WITHOUT creating the migration table (the whole point for read-only DB users). + assert!(!manager.has_table("seaql_migrations").await?); + + // The `with-self` migrator exposes the same read-only API and is likewise non-creating. + assert_eq!( + Migrator.get_pending_migrations_read_only(&db).await?.len(), + 2 + ); + assert!(!manager.has_table("seaql_migrations").await?); + + // Contrast: the ordinary (installing) variant DOES create the table. + assert_eq!( + ::get_pending_migrations(&db) + .await? + .len(), + 2 + ); + assert!(manager.has_table("seaql_migrations").await?); + + Ok(()) +} From 50d9754ed69798b35e6d8e31807497dc08c745bf Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 23 Jul 2026 20:09:12 +0100 Subject: [PATCH 2/3] Gate read_only migration test on sqlx-sqlite The test connects to sqlite::memory:, but the MySQL/Postgres CI jobs run the migrator tests with only their own backend feature (no sqlite driver), so the test failed there. Gate the whole file on the sqlx-sqlite feature. --- sea-orm-migration/tests/read_only.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sea-orm-migration/tests/read_only.rs b/sea-orm-migration/tests/read_only.rs index bc92736042..399b2e5dfa 100644 --- a/sea-orm-migration/tests/read_only.rs +++ b/sea-orm-migration/tests/read_only.rs @@ -4,6 +4,10 @@ //! //! Run: cargo test --features sqlx-sqlite,runtime-tokio-rustls --test read_only +// Uses an in-memory SQLite connection, so it only applies when the SQLite driver is built in. +// The MySQL/Postgres CI jobs run the migrator tests with only their own backend feature. +#![cfg(feature = "sqlx-sqlite")] + use sea_orm::Database; use sea_orm_migration::{MigratorTraitSelf, async_trait, migrator::MigrationStatus, prelude::*}; From f23ae87e15db5a552220c8e04407cd0363e7c7fe Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 23 Jul 2026 20:19:14 +0100 Subject: [PATCH 3/3] Cover read-only migration API on all backends Replace the sqlite-only read_only test with a run_read_only_test in the migrator integration suite, which runs against SQLite, MySQL, and Postgres via DATABASE_URL. Verifies status queries report pending without creating the table, work once it exists, and reflect applied migrations. --- sea-orm-migration/tests/main.rs | 71 +++++++++++++++++++++++ sea-orm-migration/tests/read_only.rs | 86 ---------------------------- 2 files changed, 71 insertions(+), 86 deletions(-) delete mode 100644 sea-orm-migration/tests/read_only.rs diff --git a/sea-orm-migration/tests/main.rs b/sea-orm-migration/tests/main.rs index 9d1a0240ab..3ea45509f1 100644 --- a/sea-orm-migration/tests/main.rs +++ b/sea-orm-migration/tests/main.rs @@ -47,6 +47,77 @@ async fn main() -> Result<(), DbErr> { run_transaction_test(url, "sea_orm_migration_txn", "public").await?; + run_read_only_test( + url, + default::Migrator, + "sea_orm_migration_read_only", + "public", + ) + .await?; + + Ok(()) +} + +/// Coverage for the read-only migration-status API (discussion #3141): querying status must +/// not run `CREATE TABLE`, so it works for a database user without DDL privileges. Runs on +/// every backend the suite is pointed at. +async fn run_read_only_test( + url: &str, + migrator: M, + db_name: &str, + schema: &str, +) -> Result<(), DbErr> +where + M: MigratorTraitSelf, +{ + let db = &create_db(url, db_name, schema).await?; + let manager = SchemaManager::new(db); + let table = migrator.migration_table_name().to_string(); + let total = migrator.get_migration_files().len(); + + // Fresh database: the migration table does not exist yet. + assert!(!manager.has_table(table.as_str()).await?); + + // Read-only queries report every migration as pending, WITHOUT creating the table. + assert_eq!( + migrator.get_pending_migrations_read_only(db).await?.len(), + total + ); + assert!( + migrator + .get_applied_migrations_read_only(db) + .await? + .is_empty() + ); + assert!( + migrator + .get_migration_with_status_read_only(db) + .await? + .iter() + .all(|m| m.status() == MigrationStatus::Pending) + ); + assert!(!manager.has_table(table.as_str()).await?); + + // With the table present but empty (created here by the installing path), the read-only + // queries still work and report everything pending. + migrator.install(db).await?; + assert!(manager.has_table(table.as_str()).await?); + assert_eq!( + migrator.get_pending_migrations_read_only(db).await?.len(), + total + ); + + // After applying one migration, the read-only queries reflect the applied state. + migrator.up(db, Some(1)).await?; + assert_eq!( + migrator.get_applied_migrations_read_only(db).await?.len(), + 1 + ); + assert_eq!( + migrator.get_pending_migrations_read_only(db).await?.len(), + total - 1 + ); + Ok(()) } diff --git a/sea-orm-migration/tests/read_only.rs b/sea-orm-migration/tests/read_only.rs deleted file mode 100644 index 399b2e5dfa..0000000000 --- a/sea-orm-migration/tests/read_only.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Self-contained (in-memory SQLite) coverage for the read-only migration-status API added -//! for discussion #3141: querying migration status must not create the `seaql_migrations` -//! table, so a database user without DDL privileges can use it. -//! -//! Run: cargo test --features sqlx-sqlite,runtime-tokio-rustls --test read_only - -// Uses an in-memory SQLite connection, so it only applies when the SQLite driver is built in. -// The MySQL/Postgres CI jobs run the migrator tests with only their own backend feature. -#![cfg(feature = "sqlx-sqlite")] - -use sea_orm::Database; -use sea_orm_migration::{MigratorTraitSelf, async_trait, migrator::MigrationStatus, prelude::*}; - -struct M1; -impl MigrationName for M1 { - fn name(&self) -> &str { - "m000001_first" - } -} -#[async_trait::async_trait] -impl MigrationTrait for M1 { - async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> { - Ok(()) - } -} - -struct M2; -impl MigrationName for M2 { - fn name(&self) -> &str { - "m000002_second" - } -} -#[async_trait::async_trait] -impl MigrationTrait for M2 { - async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> { - Ok(()) - } -} - -struct Migrator; -impl MigratorTrait for Migrator { - fn migrations() -> Vec> { - vec![Box::new(M1), Box::new(M2)] - } -} - -#[tokio::test] -async fn read_only_status_does_not_create_table() -> Result<(), DbErr> { - let db = Database::connect("sqlite::memory:").await?; - let manager = SchemaManager::new(&db); - - // On a fresh database the read-only queries report every migration as pending... - // (`Migrator` implements both `MigratorTrait` and `MigratorTraitSelf`, so the static - // calls are fully qualified to disambiguate — the same applies to existing methods.) - let pending = ::get_pending_migrations_read_only(&db).await?; - assert_eq!(pending.len(), 2); - assert!( - ::get_applied_migrations_read_only(&db) - .await? - .is_empty() - ); - let all = ::get_migration_with_status_read_only(&db).await?; - assert_eq!(all.len(), 2); - assert!(all.iter().all(|m| m.status() == MigrationStatus::Pending)); - - // ...WITHOUT creating the migration table (the whole point for read-only DB users). - assert!(!manager.has_table("seaql_migrations").await?); - - // The `with-self` migrator exposes the same read-only API and is likewise non-creating. - assert_eq!( - Migrator.get_pending_migrations_read_only(&db).await?.len(), - 2 - ); - assert!(!manager.has_table("seaql_migrations").await?); - - // Contrast: the ordinary (installing) variant DOES create the table. - assert_eq!( - ::get_pending_migrations(&db) - .await? - .len(), - 2 - ); - assert!(manager.has_table("seaql_migrations").await?); - - Ok(()) -}