From ccb56f83035327669310b4f7926924252ba4e7fc Mon Sep 17 00:00:00 2001 From: abhinavmir Date: Tue, 25 Aug 2026 09:59:28 -0700 Subject: [PATCH] postgres: fail when the search path no longer resolves to the migrations table The Postgres migrator refers to the migrations table by an unqualified name. Postgres resolves that name through `search_path`, which contains `"$user"` by default. Postgres ignores a search path entry that names no existing schema. A migration that creates a schema with the name of the database role therefore makes that schema shadow `public` in all later sessions. The migrator then creates a second, empty migrations table and applies every migration again. `ensure_migrations_table()` now reads the effective search path with `current_schemas(false)`. It then finds the schemas that hold the table. The table can exist in the search path, but not in the schema that an unqualified name resolves to. In that condition, migration stops with the new `MigrateError::AmbiguousMigrationsTable`. The check does not run for a table name that the user set, because that user controls how the name resolves. --- sqlx-core/src/migrate/error.rs | 31 ++++++++++ sqlx-postgres/src/migrate.rs | 67 ++++++++++++++++++++ tests/postgres/migrate.rs | 110 ++++++++++++++++++++++++++++++++- 3 files changed, 206 insertions(+), 2 deletions(-) diff --git a/sqlx-core/src/migrate/error.rs b/sqlx-core/src/migrate/error.rs index 3c08f57301..229ba00e84 100644 --- a/sqlx-core/src/migrate/error.rs +++ b/sqlx-core/src/migrate/error.rs @@ -43,6 +43,37 @@ pub enum MigrateError { #[error("database driver does not support creation of schemas at migrate time: {0}")] CreateSchemasNotSupported(String), + /// The migrations table exists, but not where an unqualified reference to it resolves. + /// + /// Currently only returned by the PostgreSQL driver, where an unqualified name is resolved + /// through `search_path`. + #[error( + "cannot migrate: `{table_name}` does not exist in the default schema `{default_schema}`, \ + but does exist at: {}.\n\n\ + This suggests that the search path for the current user or database has changed since \ + the migrations table was created. This may happen explicitly, or implicitly if a schema \ + is created with the same name as the user \ + (https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH).\n\n\ + Since SQLx cannot know which of the existing `{table_name}` tables is correct for the \ + current context, migration cannot continue.\n\n\ + To resolve this ambiguity, either change the default search path for this database \ + (using `ALTER DATABASE`), or create `{default_schema}.{table_name}` with the correct \ + set of migrations.", + .other_schemas + .iter() + .map(|schema| format!("`{schema}.{table_name}`")) + .collect::>() + .join(", ") + )] + AmbiguousMigrationsTable { + /// The unqualified name of the migrations table. + table_name: String, + /// The schema an unqualified reference to `table_name` currently resolves to. + default_schema: String, + /// The schemas in the search path that do contain `table_name`, in priority order. + other_schemas: Vec, + }, + #[error("database driver does not support skipping migrations")] SkipNotSupported(), } diff --git a/sqlx-postgres/src/migrate.rs b/sqlx-postgres/src/migrate.rs index 4afa2046c9..2a804a84fc 100644 --- a/sqlx-postgres/src/migrate.rs +++ b/sqlx-postgres/src/migrate.rs @@ -17,6 +17,12 @@ use crate::query_as::query_as; use crate::query_scalar::query_scalar; use crate::{PgConnectOptions, PgConnection, Postgres}; +/// The default name of the migrations table, as an unqualified identifier. +/// +/// If the user has picked a different name, or qualified this one, they have taken +/// responsibility for how it resolves and [`check_migrations_table_resolution()`] is skipped. +const DEFAULT_MIGRATIONS_TABLE: &str = "_sqlx_migrations"; + fn parse_for_maintenance(url: &str) -> Result<(PgConnectOptions, String), Error> { let mut options = PgConnectOptions::from_str(url)?; @@ -124,6 +130,10 @@ impl Migrate for PgConnection { table_name: &'e str, ) -> BoxFuture<'e, Result<(), MigrateError>> { Box::pin(async move { + if table_name == DEFAULT_MIGRATIONS_TABLE { + check_migrations_table_resolution(self, table_name).await?; + } + // language=SQL self.execute(AssertSqlSafe(format!( r#" @@ -312,6 +322,63 @@ CREATE TABLE IF NOT EXISTS {table_name} ( } } +/// Check that an unqualified reference to `table_name` still resolves where it was created. +/// +/// An unqualified name is resolved through `search_path`, which contains `"$user"` by default. +/// A schema is ignored while it does not exist, so a migration that creates a schema named after +/// the connecting role makes that schema shadow `public` for every later session. The migrations +/// table then resolves to the new, empty schema, and every migration is applied a second time. +/// +/// Returns [`MigrateError::AmbiguousMigrationsTable`] if `table_name` exists somewhere in the +/// search path but not in the schema an unqualified reference resolves to first, since SQLx +/// cannot tell which of those tables describes the current context. +async fn check_migrations_table_resolution( + conn: &mut PgConnection, + table_name: &str, +) -> Result<(), MigrateError> { + // `current_schemas()` returns the effective search path in priority order, with entries that + // name no existing schema omitted, which is exactly what object resolution searches. + // language=SQL + let search_path: Vec = query_scalar("SELECT current_schemas(false)::text[]") + .fetch_one(&mut *conn) + .await?; + + // With an empty search path, nothing can shadow anything, and the `CREATE TABLE` that + // follows reports the missing target schema itself. + let Some(default_schema) = search_path.first() else { + return Ok(()); + }; + + // language=SQL + let found_in: Vec = + query_scalar("SELECT schemaname::text FROM pg_catalog.pg_tables WHERE tablename = $1") + .bind(table_name) + .fetch_all(&mut *conn) + .await?; + + // Keep search path order so that the first entry is the one an unqualified name resolves to. + let mut found_in_search_path = search_path + .iter() + .filter(|schema| found_in.contains(schema)); + + match found_in_search_path.next() { + // The table does not exist yet, so it is about to be created in `default_schema` and + // will resolve there. A later move is caught on the next run. + None => Ok(()), + // The table already resolves to `default_schema`. + Some(schema) if schema == default_schema => Ok(()), + // The table exists, but an unqualified reference no longer points at it. + Some(schema) => Err(MigrateError::AmbiguousMigrationsTable { + table_name: table_name.to_owned(), + default_schema: default_schema.clone(), + other_schemas: std::iter::once(schema) + .chain(found_in_search_path) + .cloned() + .collect(), + }), + } +} + async fn execute_migration( conn: &mut PgConnection, table_name: &str, diff --git a/tests/postgres/migrate.rs b/tests/postgres/migrate.rs index c08e24c731..10e81dacb0 100644 --- a/tests/postgres/migrate.rs +++ b/tests/postgres/migrate.rs @@ -1,8 +1,9 @@ -use sqlx::migrate::Migrator; +use sqlx::migrate::{MigrateError, Migration, MigrationType, Migrator}; use sqlx::pool::PoolConnection; -use sqlx::postgres::{PgConnection, Postgres}; +use sqlx::postgres::{PgConnection, PgPool, Postgres}; use sqlx::Executor; use sqlx::Row; +use sqlx::{AssertSqlSafe, ConnectOptions, Connection, SqlSafeStr}; use std::path::Path; #[sqlx::test(migrations = false)] @@ -130,6 +131,111 @@ async fn no_tx(mut conn: PoolConnection) -> anyhow::Result<()> { Ok(()) } +/// A migration that creates a schema named after the connecting role makes that schema shadow +/// `public` in the default search path of every session that starts afterwards. An unqualified +/// reference to `_sqlx_migrations` then resolves to the new, empty schema. +/// +/// The migrator must report the ambiguity instead of creating a second migrations table and +/// applying every migration a second time. +#[sqlx::test(migrations = false)] +async fn migrations_table_shadowed_by_role_schema(pool: PgPool) -> anyhow::Result<()> { + let connect_options = pool.connect_options(); + + let role: String = sqlx::query_scalar("SELECT current_user") + .fetch_one(&pool) + .await?; + + let migrator = Migrator::with_migrations(vec![ + Migration::new( + 1, + "create role schema".into(), + MigrationType::Simple, + AssertSqlSafe(format!(r#"CREATE SCHEMA "{role}""#)).into_sql_str(), + false, + ), + Migration::new( + 2, + "add table".into(), + MigrationType::Simple, + AssertSqlSafe(format!( + r#"CREATE TABLE "{role}".migrations_shadowed_test (id INT PRIMARY KEY)"# + )) + .into_sql_str(), + false, + ), + ]); + + // The schema only shadows `public` for sessions that start after it exists, + // so each run needs its own connection. + let mut conn = connect_options.connect().await?; + migrator.run(&mut conn).await?; + conn.close().await?; + + let mut conn = connect_options.connect().await?; + let error = migrator + .run(&mut conn) + .await + .expect_err("second run did not detect the shadowed migrations table"); + conn.close().await?; + + assert!( + matches!(error, MigrateError::AmbiguousMigrationsTable { .. }), + "unexpected error: {error:?}" + ); + + // The second run must not have left an empty migrations table behind in the new schema. + let tracking_tables: Vec = sqlx::query_scalar( + "SELECT schemaname::text FROM pg_catalog.pg_tables \ + WHERE tablename = '_sqlx_migrations' ORDER BY schemaname", + ) + .fetch_all(&pool) + .await?; + + assert_eq!(tracking_tables, ["public"]); + + Ok(()) +} + +/// An explicitly chosen table name is the user's responsibility, so the shadowing check is +/// skipped for it and migrations keep running against the name as written. +#[sqlx::test(migrations = false)] +async fn qualified_migrations_table_ignores_role_schema(pool: PgPool) -> anyhow::Result<()> { + let connect_options = pool.connect_options(); + + let role: String = sqlx::query_scalar("SELECT current_user") + .fetch_one(&pool) + .await?; + + let mut migrator = Migrator::with_migrations(vec![Migration::new( + 1, + "create role schema".into(), + MigrationType::Simple, + AssertSqlSafe(format!(r#"CREATE SCHEMA "{role}""#)).into_sql_str(), + false, + )]); + migrator.dangerous_set_table_name("public._sqlx_migrations"); + + let mut conn = connect_options.connect().await?; + migrator.run(&mut conn).await?; + conn.close().await?; + + // A qualified name cannot move, so the second run is a no-op. + let mut conn = connect_options.connect().await?; + migrator.run(&mut conn).await?; + conn.close().await?; + + let tracking_tables: Vec = sqlx::query_scalar( + "SELECT schemaname::text FROM pg_catalog.pg_tables \ + WHERE tablename = '_sqlx_migrations' ORDER BY schemaname", + ) + .fetch_all(&pool) + .await?; + + assert_eq!(tracking_tables, ["public"]); + + Ok(()) +} + /// Ensure that we have a clean initial state. async fn clean_up(conn: &mut PgConnection) -> anyhow::Result<()> { conn.execute("DROP DATABASE IF EXISTS test_db").await.ok();