Skip to content

Commit 1d674f5

Browse files
postgres: only force a generic query plan where EXPLAIN is available (#4286)
The query macros set `plan_cache_mode = 'force_generic_plan'` on their describe connection (#3541) to keep nullability inference from being skewed by the NULL placeholder arguments. It was issued inside a `DO` block, which CockroachDB rejects at parse time -- `SET` inside a function body, SQLSTATE 0A000 -- breaking the query macros against CockroachDB since 0.9.0 (#4274). The setting only benefits the EXPLAIN-based inference, which already runs only when `is_explain_available()` is true (false for CockroachDB, Materialize and QuestDB). Gate the setting on that same check so it is never issued on a database that cannot use it, replacing the `pg_settings` probe with a `server_version_num() >= 12` guard.
1 parent ea7d589 commit 1d674f5

4 files changed

Lines changed: 75 additions & 19 deletions

File tree

sqlx-macros-core/src/database/impls.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ macro_rules! impl_database_ext {
33
$database:path,
44
row: $row:path,
55
$(describe-blocking: $describe:path,)?
6+
$(prepare-connection: $prepare:path,)?
67
) => {
78
impl $crate::database::DatabaseExt for $database {
89
const DATABASE_PATH: &'static str = stringify!($database);
910
const ROW_PATH: &'static str = stringify!($row);
1011
impl_describe_blocking!($database, $($describe)?);
12+
impl_prepare_describe_connection!($database, $($prepare)?);
1113
}
1214
}
1315
}
@@ -38,6 +40,19 @@ macro_rules! impl_describe_blocking {
3840
};
3941
}
4042

43+
macro_rules! impl_prepare_describe_connection {
44+
($database:path $(,)?) => {
45+
// No override: use the `DatabaseExt::prepare_describe_connection` default (no-op).
46+
};
47+
($database:path, $prepare:path) => {
48+
async fn prepare_describe_connection(
49+
conn: &mut <Self as sqlx_core::database::Database>::Connection,
50+
) -> sqlx_core::Result<()> {
51+
$prepare(conn).await
52+
}
53+
};
54+
}
55+
4156
// The paths below will also be emitted from the macros, so they need to match the final facade.
4257
mod sqlx {
4358
#[cfg(feature = "mysql")]
@@ -61,6 +76,7 @@ impl_database_ext! {
6176
impl_database_ext! {
6277
sqlx::postgres::Postgres,
6378
row: sqlx::postgres::PgRow,
79+
prepare-connection: sqlx::postgres::PgConnection::force_generic_plan_for_describe,
6480
}
6581

6682
#[cfg(feature = "_sqlite")]

sqlx-macros-core/src/database/mod.rs

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ pub trait DatabaseExt: Database + TypeChecking {
3030
database_url: &str,
3131
driver_config: &config::drivers::Config,
3232
) -> sqlx_core::Result<Describe<Self>>;
33+
34+
/// Prepare a freshly-opened connection used by the query macros for `describe`.
35+
///
36+
/// Defaults to a no-op. Postgres overrides this to force a generic query plan,
37+
/// which gives more accurate nullability inference for parameterized queries
38+
/// (see launchbadge/sqlx#3541). The override is gated so it is skipped where it
39+
/// doesn't apply -- e.g. CockroachDB, which rejected the previous implementation
40+
/// (see launchbadge/sqlx#4274).
41+
#[allow(async_fn_in_trait)]
42+
async fn prepare_describe_connection(_conn: &mut Self::Connection) -> sqlx_core::Result<()> {
43+
Ok(())
44+
}
3345
}
3446

3547
#[allow(dead_code)]
@@ -65,25 +77,7 @@ impl<DB: DatabaseExt> CachingDescribeBlocking<DB> {
6577
hash_map::Entry::Occupied(hit) => hit.into_mut(),
6678
hash_map::Entry::Vacant(miss) => {
6779
let conn = miss.insert(DB::Connection::connect(database_url).await?);
68-
69-
#[cfg(feature = "postgres")]
70-
if DB::NAME == sqlx_postgres::Postgres::NAME {
71-
conn.execute(
72-
"
73-
DO $$
74-
BEGIN
75-
IF EXISTS (
76-
SELECT 1
77-
FROM pg_settings
78-
WHERE name = 'plan_cache_mode'
79-
) THEN
80-
SET SESSION plan_cache_mode = 'force_generic_plan';
81-
END IF;
82-
END $$;
83-
",
84-
)
85-
.await?;
86-
}
80+
DB::prepare_describe_connection(conn).await?;
8781
conn
8882
}
8983
};

sqlx-postgres/src/connection/describe.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::error::Error;
2+
use crate::executor::Executor;
23
use crate::io::StatementId;
34
use crate::query_as::query_as;
45
use crate::statement::PgStatementMetadata;
@@ -18,6 +19,29 @@ impl PgConnection {
1819
!is_cockroachdb && !is_materialize && !is_questdb
1920
}
2021

22+
/// Prepare a freshly-opened connection that the query macros use for `describe`.
23+
///
24+
/// Forces a generic query plan so that nullability inference via `EXPLAIN` reflects
25+
/// the real query shape, rather than a plan specialized for the `NULL` placeholder
26+
/// arguments bound while describing a statement.
27+
/// See <https://github.com/launchbadge/sqlx/pull/3541>.
28+
///
29+
/// Gated on `is_explain_available()`: the setting only matters for the `EXPLAIN`-based
30+
/// inference, which is skipped on databases that don't support it -- CockroachDB,
31+
/// Materialize and QuestDB -- and on the server version, as `plan_cache_mode` was only
32+
/// introduced in PostgreSQL 12. Issuing it unconditionally previously broke the macros
33+
/// against CockroachDB, which rejects `SET` inside the `DO` block this used to run
34+
/// (`SQLSTATE 0A000`). See <https://github.com/launchbadge/sqlx/issues/4274>.
35+
#[doc(hidden)]
36+
pub async fn force_generic_plan_for_describe(&mut self) -> Result<(), Error> {
37+
if self.is_explain_available() && self.server_version_num().is_some_and(|v| v >= 120_000) {
38+
self.execute("SET plan_cache_mode = 'force_generic_plan'")
39+
.await?;
40+
}
41+
42+
Ok(())
43+
}
44+
2145
pub(crate) async fn get_nullable_for_columns(
2246
&mut self,
2347
stmt_id: StatementId,

tests/postgres/describe.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,28 @@ async fn it_describes_expression() -> anyhow::Result<()> {
4242
Ok(())
4343
}
4444

45+
// Regression test for launchbadge/sqlx#3541 and #4274: the query macros force a
46+
// generic query plan on their describe connection so that nullability inference via
47+
// `EXPLAIN` isn't skewed by the `NULL` placeholder arguments bound during `describe`.
48+
// This checks the mechanism still applies on PostgreSQL.
49+
//
50+
// The complementary half of #4274 -- skipping this on databases without `EXPLAIN`
51+
// support (CockroachDB, Materialize, QuestDB), where the previous `DO` block failed
52+
// to even parse -- cannot be exercised here, as CI runs no such database.
53+
#[sqlx_macros::test]
54+
async fn it_forces_generic_plan_for_describe() -> anyhow::Result<()> {
55+
let mut conn = new::<Postgres>().await?;
56+
57+
conn.force_generic_plan_for_describe().await?;
58+
59+
let mode: String = sqlx::query_scalar("SHOW plan_cache_mode")
60+
.fetch_one(&mut conn)
61+
.await?;
62+
assert_eq!(mode, "force_generic_plan");
63+
64+
Ok(())
65+
}
66+
4567
#[sqlx_macros::test]
4668
async fn it_describes_enum() -> anyhow::Result<()> {
4769
let mut conn = new::<Postgres>().await?;

0 commit comments

Comments
 (0)