Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 46 additions & 0 deletions sea-orm-migration/src/migrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<C>(db: &C) -> Result<Vec<Migration>, 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<C>(db: &C) -> Result<Vec<Migration>, 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<C>(db: &C) -> Result<Vec<Migration>, 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<C>(db: &C) -> Result<(), DbErr>
where
Expand Down
16 changes: 16 additions & 0 deletions sea-orm-migration/src/migrator/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ where
.collect()
}

pub async fn get_migration_models_read_only<C>(
db: &C,
migration_table_name: DynIden,
) -> Result<Vec<seaql_migrations::Model>, 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>,
migration_models: Vec<seaql_migrations::Model>,
Expand Down
69 changes: 69 additions & 0 deletions sea-orm-migration/src/migrator/with_self.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<C>(&self, db: &C) -> Result<Vec<Migration>, 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<C>(&self, db: &C) -> Result<Vec<Migration>, 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<C>(&self, db: &C) -> Result<Vec<Migration>, 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<C>(&self, db: &C) -> Result<(), DbErr>
where
Expand Down Expand Up @@ -202,6 +250,27 @@ where
M::get_pending_migrations(db).await
}

async fn get_migration_with_status_read_only<C>(&self, db: &C) -> Result<Vec<Migration>, DbErr>
where
C: ConnectionTrait,
{
M::get_migration_with_status_read_only(db).await
}

async fn get_pending_migrations_read_only<C>(&self, db: &C) -> Result<Vec<Migration>, DbErr>
where
C: ConnectionTrait,
{
M::get_pending_migrations_read_only(db).await
}

async fn get_applied_migrations_read_only<C>(&self, db: &C) -> Result<Vec<Migration>, DbErr>
where
C: ConnectionTrait,
{
M::get_applied_migrations_read_only(db).await
}

async fn get_applied_migrations<C>(&self, db: &C) -> Result<Vec<Migration>, DbErr>
where
C: ConnectionTrait,
Expand Down
71 changes: 71 additions & 0 deletions sea-orm-migration/tests/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<M>(
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(())
}

Expand Down
Loading