diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e875790b..a84e15f1a 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 bdae6073d..389ad7e8a 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 daaaeadc0..a02753d04 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 d679bb0f8..d92fd084e 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/main.rs b/sea-orm-migration/tests/main.rs index 9d1a0240a..3ea45509f 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(()) }