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 <yvanwww@gmail.com>

* 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 <yvanwww@gmail.com>

---------

Signed-off-by: BootstrapperSBL <yvanwww@gmail.com>
This commit is contained in:
Yvan Wang
2026-04-26 22:01:35 +08:00
committed by GitHub
parent 0effc30778
commit 793545d8e6
2 changed files with 73 additions and 4 deletions

View File

@@ -707,10 +707,19 @@ impl Instance {
) -> Result<Option<DescribeResult>> {
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::<PermissionCheckerRef>()
.as_ref()

View File

@@ -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;