From 793545d8e6a71745ca747be0ef6e1d47b430c39d Mon Sep 17 00:00:00 2001 From: Yvan Wang <131545713+BootstrapperSBL@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:01:35 +0800 Subject: [PATCH] fix(server): describe EXPLAIN statements so bind parameters work (#8035) * fix(server): describe EXPLAIN statements so bind parameters work `do_describe_inner` only planned `Insert`/`Query`/`Delete`, so `EXPLAIN` and `EXPLAIN ANALYZE` fell through to the non-plan branch and had no parameter-type inference. At Bind time the Postgres handler then reported `unsupported_parameter_type` even though the inner query would have worked on its own. Recurse one level into `Statement::Explain` so that an EXPLAIN wrapping a plannable statement goes through the same describe path. Adds a tokio-postgres integration test that exercises `EXPLAIN`/`EXPLAIN ANALYZE` over the extended query protocol. Fixes #8029 Signed-off-by: BootstrapperSBL * refactor(server): extract plannable-inner check into closure Reduce duplication between the direct match and the EXPLAIN inner match by factoring out is_inner_plannable. Behaviour unchanged. Signed-off-by: BootstrapperSBL --------- Signed-off-by: BootstrapperSBL --- src/frontend/src/instance.rs | 17 +++++++--- tests-integration/tests/sql.rs | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/instance.rs b/src/frontend/src/instance.rs index 14b8d44831..13aa388a7d 100644 --- a/src/frontend/src/instance.rs +++ b/src/frontend/src/instance.rs @@ -707,10 +707,19 @@ impl Instance { ) -> Result> { ensure!(!self.is_suspended(), error::SuspendedSnafu); - if matches!( - stmt, - Statement::Insert(_) | Statement::Query(_) | Statement::Delete(_) - ) { + // EXPLAIN / EXPLAIN ANALYZE wrap an inner statement; describe them when the + // wrapped statement is something we already plan (so that bind parameters + // in the inner query get their types inferred). See #8029. + let is_inner_plannable = |s: &Statement| { + matches!( + s, + Statement::Insert(_) | Statement::Query(_) | Statement::Delete(_) + ) + }; + let plannable = is_inner_plannable(&stmt) + || matches!(&stmt, Statement::Explain(explain) if is_inner_plannable(explain.statement.as_ref())); + + if plannable { self.plugins .get::() .as_ref() diff --git a/tests-integration/tests/sql.rs b/tests-integration/tests/sql.rs index 0416316053..14972d900b 100644 --- a/tests-integration/tests/sql.rs +++ b/tests-integration/tests/sql.rs @@ -83,6 +83,7 @@ macro_rules! sql_tests { test_postgres_intervalstyle, test_postgres_parameter_inference, test_postgres_uint64_parameter, + test_postgres_explain_bind_parameter, test_postgres_array_types, test_mysql_prepare_stmt_insert_timestamp, test_mysql_federated_prepare_stmt, @@ -1353,6 +1354,65 @@ pub async fn test_postgres_uint64_parameter(store_type: StorageType) { guard.remove_all().await; } +pub async fn test_postgres_explain_bind_parameter(store_type: StorageType) { + // Regression test for #8029: EXPLAIN / EXPLAIN ANALYZE must accept bind + // parameters over the Postgres extended query protocol. + let (mut guard, fe_pg_server) = + setup_pg_server(store_type, "test_postgres_explain_bind_parameter").await; + let addr = fe_pg_server.bind_addr().unwrap().to_string(); + + let (client, connection) = tokio_postgres::connect(&format!("postgres://{addr}/public"), NoTls) + .await + .unwrap(); + + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + connection.await.unwrap(); + tx.send(()).unwrap(); + }); + + let _ = client + .simple_query( + "create table t (k varchar(36) not null, ts timestamp(3) not null, time index(ts))", + ) + .await + .unwrap(); + let _ = client + .simple_query("insert into t (k, ts) values ('a', 1), ('b', 2), ('c', 3)") + .await + .unwrap(); + + // Sanity check: the underlying SELECT with a bind parameter works. + let rows = client + .query("SELECT k FROM t WHERE k = $1", &[&"a"]) + .await + .unwrap(); + assert_eq!(1, rows.len()); + + // EXPLAIN with a bind parameter must succeed. + let rows = client + .query("EXPLAIN SELECT k FROM t WHERE k = $1", &[&"a"]) + .await + .unwrap(); + assert!(!rows.is_empty(), "EXPLAIN should produce at least one row"); + + // EXPLAIN ANALYZE with a bind parameter must also succeed. + let rows = client + .query("EXPLAIN ANALYZE SELECT k FROM t WHERE k = $1", &[&"a"]) + .await + .unwrap(); + assert!( + !rows.is_empty(), + "EXPLAIN ANALYZE should produce at least one row" + ); + + drop(client); + rx.await.unwrap(); + + let _ = fe_pg_server.shutdown().await; + guard.remove_all().await; +} + pub async fn test_mysql_async_timestamp(store_type: StorageType) { use mysql_async::prelude::*; use time::PrimitiveDateTime;