From 78e717bea605109dc6a98e8be4b769a5df226379 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Thu, 24 Sep 2026 17:21:38 +0800 Subject: [PATCH] refactor(plan): converge estimated plan capability and strategy --- crates/dbx-core/src/query/plugin_plan.rs | 141 +++++++----- crates/dbx-sql/src/query_execution_sql.rs | 264 +++++++++++++++------- 2 files changed, 270 insertions(+), 135 deletions(-) diff --git a/crates/dbx-core/src/query/plugin_plan.rs b/crates/dbx-core/src/query/plugin_plan.rs index 790ef2c951..fac177d117 100644 --- a/crates/dbx-core/src/query/plugin_plan.rs +++ b/crates/dbx-core/src/query/plugin_plan.rs @@ -25,8 +25,8 @@ use crate::connection::AppState; use crate::db::QueryResult; use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::query_execution_sql::{ - build_explain_sql, estimated_plan_format, supports_explain_plan, EstimatedPlanFormat, ExplainFormat, - ExplainSqlOptions, + build_explain_sql, estimated_plan_strategy, supports_explain_plan, EstimatedPlanAcquisition, EstimatedPlanFormat, + EstimatedPlanStrategy, ExplainFormat, ExplainSqlOptions, }; use super::{execute_sql_statement_with_options_typed, QueryExecutionMode, QueryExecutionOptions}; @@ -166,33 +166,35 @@ pub async fn explain_estimated_plan(state: &AppState, request: PluginPlanRequest let config = connection_config(state, &request.connection_id).await?; require_open_connection(state, &request.connection_id).await?; let database_type = explain_database_type(&config); - if !supports_explain_plan(Some(database_type)) { - return Err(unsupported_dialect_message(database_type)); - } + let strategy = + estimated_plan_strategy(Some(database_type)).ok_or_else(|| unsupported_dialect_message(database_type))?; let timeout_secs = plugin_plan_timeout_secs(request.timeout_ms, &config); - let (plan_text, rows_truncated) = if uses_driver_native_plan(database_type) { - // Dameng and Oracle hand back a native plan listing from their driver; - // Oracle's `EXPLAIN PLAN FOR` alone only fills `PLAN_TABLE`. This reuses - // the core the AI and command explain paths already use, pinned to - // `mode = "explain"` so Dameng autotrace stays unreachable. - let text = get_agent_explain_info_core( - state, - &request.connection_id, - request.database.as_deref(), - request.schema.as_deref(), - &request.sql, - Some(AGENT_EXPLAIN_MODE), - Some(timeout_secs), - ) - .await?; - (text, false) - } else { - native_estimated_plan(state, &request, database_type, timeout_secs).await? + let (plan_text, rows_truncated) = match strategy.acquisition() { + EstimatedPlanAcquisition::DriverNative => { + // Dameng and Oracle hand back a native plan listing from their driver; + // Oracle's `EXPLAIN PLAN FOR` alone only fills `PLAN_TABLE`. This reuses + // the core the AI and command explain paths already use, pinned to + // `mode = "explain"` so Dameng autotrace stays unreachable. + let text = get_agent_explain_info_core( + state, + &request.connection_id, + request.database.as_deref(), + request.schema.as_deref(), + &request.sql, + Some(AGENT_EXPLAIN_MODE), + Some(timeout_secs), + ) + .await?; + (text, false) + } + EstimatedPlanAcquisition::GeneratedSql | EstimatedPlanAcquisition::SqlServerShowPlanSession => { + native_estimated_plan(state, &request, database_type, strategy, timeout_secs).await? + } }; let plan_text = non_empty_plan_text(plan_text)?; - let mut payload = finalize_plan_payload(database_type, plan_text)?; + let mut payload = finalize_plan_payload(strategy.format(), plan_text)?; if rows_truncated { payload.truncated = true; payload.warnings.push(WARNING_PLAN_ROWS_TRUNCATED.to_string()); @@ -208,16 +210,11 @@ pub async fn explain_estimated_plan(state: &AppState, request: PluginPlanRequest }) } -/// Dialects whose plan comes from the driver/oracle-side native plan listing -/// instead of a generated `EXPLAIN` statement. -fn uses_driver_native_plan(database_type: DatabaseType) -> bool { - matches!(database_type, DatabaseType::Dameng | DatabaseType::Oracle) -} - async fn native_estimated_plan( state: &AppState, request: &PluginPlanRequest, database_type: DatabaseType, + strategy: EstimatedPlanStrategy, timeout_secs: u64, ) -> Result<(String, bool), String> { let built = build_explain_sql(ExplainSqlOptions { @@ -235,8 +232,14 @@ async fn native_estimated_plan( return Err("The host did not build an EXPLAIN statement".to_string()); }; - if database_type == DatabaseType::SqlServer { - return sqlserver_estimated_plan(state, request, timeout_secs).await; + match strategy.acquisition() { + EstimatedPlanAcquisition::GeneratedSql => {} + EstimatedPlanAcquisition::SqlServerShowPlanSession => { + return sqlserver_estimated_plan(state, request, timeout_secs).await; + } + EstimatedPlanAcquisition::DriverNative => { + return Err("Driver-native plans must use the shared driver explain path".to_string()); + } } let database = request.database.as_deref().unwrap_or_default(); @@ -364,8 +367,8 @@ fn non_empty_plan_text(text: String) -> Result { /// Decodes the acquired text into the shape advertised by `format`, downgrades /// an unparseable JSON answer to text instead of lying about it, and enforces /// the payload cap. -fn finalize_plan_payload(database_type: DatabaseType, text: String) -> Result { - let mut format = estimated_plan_format(Some(database_type)); +fn finalize_plan_payload(plan_format: EstimatedPlanFormat, text: String) -> Result { + let mut format = plan_format; let mut warnings = Vec::new(); let mut raw_plan = Value::String(text); @@ -657,14 +660,14 @@ mod tests { #[test] fn parses_json_plans_and_downgrades_other_payloads_to_text() { let payload = - finalize_plan_payload(DatabaseType::Postgres, r#"[{"Plan": {"Node Type": "Seq Scan"}}]"#.to_string()) + finalize_plan_payload(EstimatedPlanFormat::Json, r#"[{"Plan": {"Node Type": "Seq Scan"}}]"#.to_string()) .unwrap(); assert_eq!(payload.format, EstimatedPlanFormat::Json); assert_eq!(payload.raw_plan[0]["Plan"]["Node Type"], Value::String("Seq Scan".to_string())); assert!(!payload.truncated); assert!(payload.warnings.is_empty()); - let payload = finalize_plan_payload(DatabaseType::Postgres, "Seq Scan on users".to_string()).unwrap(); + let payload = finalize_plan_payload(EstimatedPlanFormat::Json, "Seq Scan on users".to_string()).unwrap(); assert_eq!(payload.format, EstimatedPlanFormat::Text); assert_eq!(payload.raw_plan, Value::String("Seq Scan on users".to_string())); assert_eq!(payload.warnings, vec![WARNING_PLAN_NOT_JSON.to_string()]); @@ -672,12 +675,12 @@ mod tests { #[test] fn keeps_text_and_xml_plans_as_strings() { - let text = finalize_plan_payload(DatabaseType::Oracle, "1 #NSET2: [0, 1, 0]".to_string()).unwrap(); + let text = finalize_plan_payload(EstimatedPlanFormat::Text, "1 #NSET2: [0, 1, 0]".to_string()).unwrap(); assert_eq!(text.format, EstimatedPlanFormat::Text); assert_eq!(text.raw_plan, Value::String("1 #NSET2: [0, 1, 0]".to_string())); let xml = - finalize_plan_payload(DatabaseType::SqlServer, "".to_string()) + finalize_plan_payload(EstimatedPlanFormat::Xml, "".to_string()) .unwrap(); assert_eq!(xml.format, EstimatedPlanFormat::Xml); assert_eq!(xml.raw_plan, Value::String("".to_string())); @@ -687,7 +690,7 @@ mod tests { #[test] fn truncates_oversized_text_plans_and_rejects_oversized_json_plans() { let oversized = "x".repeat(MAX_PLUGIN_PLAN_BYTES + 1_024); - let payload = finalize_plan_payload(DatabaseType::Doris, oversized.clone()).unwrap(); + let payload = finalize_plan_payload(EstimatedPlanFormat::Text, oversized.clone()).unwrap(); assert!(payload.truncated); assert_eq!(payload.warnings, vec![WARNING_PLAN_TRUNCATED.to_string()]); let Value::String(text) = &payload.raw_plan else { @@ -698,7 +701,7 @@ mod tests { // A JSON document must not be handed over half-parsed. let oversized_json = format!(r#"[{{"Plan": "{}"}}]"#, "y".repeat(MAX_PLUGIN_PLAN_BYTES + 1_024)); - let error = finalize_plan_payload(DatabaseType::Postgres, oversized_json).unwrap_err(); + let error = finalize_plan_payload(EstimatedPlanFormat::Json, oversized_json).unwrap_err(); assert!(error.contains("host limit"), "{error}"); } @@ -758,15 +761,23 @@ mod tests { } #[test] - fn plans_native_dialects_through_the_driver_and_the_rest_through_explain_sql() { - assert!(uses_driver_native_plan(DatabaseType::Dameng)); - assert!(uses_driver_native_plan(DatabaseType::Oracle)); + fn plugin_acquisition_paths_match_estimated_plan_strategy() { + use EstimatedPlanAcquisition::{DriverNative, GeneratedSql, SqlServerShowPlanSession}; - for database_type in - [DatabaseType::Postgres, DatabaseType::Mysql, DatabaseType::SqlServer, DatabaseType::OceanbaseOracle] - { - assert!(!uses_driver_native_plan(database_type)); - assert!(supports_explain_plan(Some(database_type))); + for (database_type, expected_acquisition) in [ + (DatabaseType::Mysql, GeneratedSql), + (DatabaseType::Doris, GeneratedSql), + (DatabaseType::Postgres, GeneratedSql), + (DatabaseType::Questdb, GeneratedSql), + (DatabaseType::Dameng, DriverNative), + (DatabaseType::Oracle, DriverNative), + (DatabaseType::OceanbaseOracle, GeneratedSql), + (DatabaseType::SqlServer, SqlServerShowPlanSession), + ] { + let strategy = estimated_plan_strategy(Some(database_type)) + .unwrap_or_else(|| panic!("{database_type:?} must have an acquisition strategy")); + assert!(supports_explain_plan(Some(database_type)), "{database_type:?}"); + assert_eq!(strategy.acquisition(), expected_acquisition, "{database_type:?}"); } } @@ -853,15 +864,31 @@ mod tests { } #[tokio::test] - async fn capabilities_report_doris_for_the_mysql_doris_profile() { - let mut doris = config(DatabaseType::Mysql, 30); - doris.driver_profile = Some("doris".to_string()); - let (state, _dir) = saved_connection_state(std::slice::from_ref(&doris)).await; - open_connection(&state, &doris.id).await; + async fn capabilities_resolve_mysql_profiles_before_selecting_plan_strategy() { + let cases = [ + ("mysql-doris", Some("doris"), "doris"), + ("mysql-selectdb", Some("selectdb"), "doris"), + ("mysql-native", None, "mysql"), + ("mysql-profile", Some("mysql"), "mysql"), + ("mysql-starrocks", Some("starrocks"), "mysql"), + ]; + let configs = cases + .iter() + .map(|(id, profile, _)| { + let mut config = config_for(DatabaseType::Mysql, id, 30); + config.driver_profile = profile.map(|profile| profile.to_string()); + config + }) + .collect::>(); + let (state, _dir) = saved_connection_state(&configs).await; - let capabilities = plugin_plan_capabilities(&state, &doris.id).await.unwrap(); - let capabilities = serde_json::to_value(capabilities).unwrap(); - assert_eq!(capabilities["dbType"], "doris"); - assert!(capabilities["supports"]["estimatedPlan"].as_bool().unwrap_or_default()); + for ((_, _, expected_db_type), config) in cases.iter().zip(configs.iter()) { + open_connection(&state, &config.id).await; + let capabilities = plugin_plan_capabilities(&state, &config.id) + .await + .unwrap_or_else(|error| panic!("capabilities should be available for {}: {error}", config.id)); + assert_eq!(capabilities.db_type, *expected_db_type, "{}", config.id); + assert!(capabilities.supports.estimated_plan, "{}", config.id); + } } } diff --git a/crates/dbx-sql/src/query_execution_sql.rs b/crates/dbx-sql/src/query_execution_sql.rs index 10e967aae5..519a00a2ca 100644 --- a/crates/dbx-sql/src/query_execution_sql.rs +++ b/crates/dbx-sql/src/query_execution_sql.rs @@ -42,7 +42,7 @@ pub struct ExplainSqlBuildResult { /// Callers that hand a raw plan to a consumer which cannot inspect the SQL /// (the plugin Host API) need to know how to decode it. Keep this in sync with /// the generated statements in [`build_explain_sql`]; -/// `estimated_plan_format_matches_generated_sql` guards the pairing. +/// `estimated_plan_strategy_matches_capability_sql_format_and_acquisition` guards the pairing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum EstimatedPlanFormat { @@ -64,6 +64,51 @@ impl EstimatedPlanFormat { } } +/// The execution contract used to acquire an estimated plan. +/// +/// This describes the acquisition path only; connection and driver execution +/// remain owned by `dbx-core`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EstimatedPlanAcquisition { + /// Execute the SQL generated by `build_explain_sql`. + GeneratedSql, + /// Ask the connected driver's existing explain facility for a plan. + DriverNative, + /// Capture SHOWPLAN on a dedicated SQL Server client session. + SqlServerShowPlanSession, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EstimatedPlanSqlGeneration { + Postgres, + Mysql, + Plain, + Oracle, + SqlServer, + Json, +} + +/// One complete Estimated Plan contract for a database dialect. +/// +/// Capability, SQL generation, payload encoding and Plugin Host acquisition +/// all derive from this mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EstimatedPlanStrategy { + sql_generation: EstimatedPlanSqlGeneration, + acquisition: EstimatedPlanAcquisition, + format: EstimatedPlanFormat, +} + +impl EstimatedPlanStrategy { + pub const fn acquisition(self) -> EstimatedPlanAcquisition { + self.acquisition + } + + pub const fn format(self) -> EstimatedPlanFormat { + self.format + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DroppedFilePreviewSqlOptions { @@ -73,9 +118,9 @@ pub struct DroppedFilePreviewSqlOptions { } pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult { - if !supports_explain_plan(options.database_type) { + let Some(strategy) = estimated_plan_strategy(options.database_type) else { return explain_err("unsupported"); - } + }; let source = strip_trailing_semicolons(options.sql.trim()); if source.is_empty() { @@ -96,33 +141,30 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult { return explain_err("unsafe"); } - let sql = match options.database_type { - // ANALYZE executes the statement; is_safe_explain_sql has already limited - // the source to SELECT/WITH/TABLE/VALUES. MongoDb shares the plain arm only. - Some(DatabaseType::Postgres) if options.analyze == Some(true) => { + let sql = match strategy.sql_generation { + // ANALYZE executes the statement; the safety gate above has already + // limited the source to the existing read-only subset. + EstimatedPlanSqlGeneration::Postgres if options.analyze == Some(true) => { format!("EXPLAIN (ANALYZE, FORMAT JSON) {source}") } - Some(DatabaseType::Postgres | DatabaseType::MongoDb) => { - format!("EXPLAIN (FORMAT JSON) {source}") - } - Some(DatabaseType::Dameng | DatabaseType::Questdb) => { - format!("EXPLAIN {source}") - } - Some(DatabaseType::Doris) => format!("EXPLAIN {source}"), - Some(DatabaseType::Oracle) => format!("EXPLAIN PLAN FOR {source}"), + EstimatedPlanSqlGeneration::Postgres => format!("EXPLAIN (FORMAT JSON) {source}"), + EstimatedPlanSqlGeneration::Plain => format!("EXPLAIN {source}"), + EstimatedPlanSqlGeneration::Oracle => format!("EXPLAIN PLAN FOR {source}"), // STATISTICS XML returns the same ShowPlanXML document plus per-operator // runtime counters, at the price of actually running the statement. - Some(DatabaseType::SqlServer) if options.analyze == Some(true) => { + EstimatedPlanSqlGeneration::SqlServer if options.analyze == Some(true) => { format!("SET STATISTICS XML ON;\nGO\n{source}\nGO\nSET STATISTICS XML OFF;") } - Some(DatabaseType::SqlServer) => { + EstimatedPlanSqlGeneration::SqlServer => { format!("SET SHOWPLAN_XML ON;\nGO\n{source}\nGO\nSET SHOWPLAN_XML OFF;") } - Some(DatabaseType::Mysql) if options.format == Some(ExplainFormat::Standard) => { + EstimatedPlanSqlGeneration::Mysql if options.format == Some(ExplainFormat::Standard) => { // MySQL 8.0.32+ may otherwise inherit TREE or JSON from the session-level explain_format. format!("EXPLAIN FORMAT=TRADITIONAL {source}") } - _ => format!("EXPLAIN FORMAT=JSON {source}"), + EstimatedPlanSqlGeneration::Mysql | EstimatedPlanSqlGeneration::Json => { + format!("EXPLAIN FORMAT=JSON {source}") + } }; ExplainSqlBuildResult { ok: true, sql: Some(sql), reason: None } } @@ -146,34 +188,51 @@ pub fn build_dropped_file_preview_sql(options: DroppedFilePreviewSqlOptions) -> None } -/// How a consumer should decode the estimated plan `build_explain_sql` asks -/// the server for. Dameng and Oracle plans reach DBX through the driver's -/// native plan text rather than a generated `EXPLAIN` statement, and every -/// agent plan is text. -pub fn estimated_plan_format(database_type: Option) -> EstimatedPlanFormat { - match database_type { - Some(DatabaseType::SqlServer) => EstimatedPlanFormat::Xml, - Some(DatabaseType::Dameng | DatabaseType::Questdb | DatabaseType::Doris | DatabaseType::Oracle) => { - EstimatedPlanFormat::Text +/// The estimated-plan contract for a database, if one is currently supported. +pub fn estimated_plan_strategy(database_type: Option) -> Option { + use EstimatedPlanAcquisition::{DriverNative, GeneratedSql, SqlServerShowPlanSession}; + use EstimatedPlanFormat::{Json, Text, Xml}; + use EstimatedPlanSqlGeneration::{Mysql, Oracle, Plain, Postgres, SqlServer}; + + let strategy = match database_type? { + DatabaseType::Mysql => EstimatedPlanStrategy { sql_generation: Mysql, acquisition: GeneratedSql, format: Json }, + DatabaseType::Doris => EstimatedPlanStrategy { sql_generation: Plain, acquisition: GeneratedSql, format: Text }, + DatabaseType::Postgres => { + EstimatedPlanStrategy { sql_generation: Postgres, acquisition: GeneratedSql, format: Json } } - _ => EstimatedPlanFormat::Json, - } + DatabaseType::Questdb => { + EstimatedPlanStrategy { sql_generation: Plain, acquisition: GeneratedSql, format: Text } + } + DatabaseType::Dameng => { + EstimatedPlanStrategy { sql_generation: Plain, acquisition: DriverNative, format: Text } + } + DatabaseType::Oracle => { + EstimatedPlanStrategy { sql_generation: Oracle, acquisition: DriverNative, format: Text } + } + DatabaseType::OceanbaseOracle => EstimatedPlanStrategy { + sql_generation: EstimatedPlanSqlGeneration::Json, + acquisition: GeneratedSql, + format: Json, + }, + DatabaseType::SqlServer => { + EstimatedPlanStrategy { sql_generation: SqlServer, acquisition: SqlServerShowPlanSession, format: Xml } + } + // The former MongoDB SQL-generation arm was unreachable behind the old + // capability gate; it remains unsupported and has no Estimated Plan strategy. + _ => return None, + }; + Some(strategy) +} + +/// Payload encoding defined by the estimated-plan strategy for this dialect. +/// Keep the historical JSON fallback for unsupported/unspecified dialects; +/// callers must check capability separately before using it. +pub fn estimated_plan_format(database_type: Option) -> EstimatedPlanFormat { + estimated_plan_strategy(database_type).map_or(EstimatedPlanFormat::Json, EstimatedPlanStrategy::format) } pub fn supports_explain_plan(database_type: Option) -> bool { - matches!( - database_type, - Some( - DatabaseType::Mysql - | DatabaseType::Doris - | DatabaseType::Postgres - | DatabaseType::Questdb - | DatabaseType::Dameng - | DatabaseType::Oracle - | DatabaseType::OceanbaseOracle - | DatabaseType::SqlServer - ) - ) + estimated_plan_strategy(database_type).is_some() } pub fn is_safe_explain_sql(sql: &str) -> bool { @@ -1398,9 +1457,11 @@ mod tests { for (db_type, expected) in [ (DatabaseType::MongoDb, "EXPLAIN (FORMAT JSON) SELECT 1"), (DatabaseType::Mysql, "EXPLAIN FORMAT=JSON SELECT 1"), - (DatabaseType::Dameng, "EXPLAIN SELECT 1"), + (DatabaseType::Doris, "EXPLAIN SELECT 1"), (DatabaseType::Questdb, "EXPLAIN SELECT 1"), + (DatabaseType::Dameng, "EXPLAIN SELECT 1"), (DatabaseType::Oracle, "EXPLAIN PLAN FOR SELECT 1"), + (DatabaseType::OceanbaseOracle, "EXPLAIN FORMAT=JSON SELECT 1"), ] { let analyzed = build_explain_sql(ExplainSqlOptions { database_type: Some(db_type), @@ -1416,8 +1477,8 @@ mod tests { }); assert_eq!(analyzed, plain, "{db_type:?} must ignore the analyze flag"); - // MongoDb is rejected earlier by supports_explain_plan, so it never - // reaches the match arm it nominally shares with Postgres. + // MongoDb is rejected by the strategy capability gate; its historical + // SQL-generation arm remains unreachable. if db_type != DatabaseType::MongoDb { assert_eq!(analyzed.sql, Some(expected.to_string()), "{db_type:?}"); } else { @@ -1570,44 +1631,71 @@ mod tests { } #[test] - fn estimated_plan_format_matches_generated_sql() { - // Every dialect `supports_explain_plan` advertises must declare a payload - // format that the statement `build_explain_sql` generates really produces. - for database_type in - DatabaseType::ALL.iter().copied().filter(|database_type| supports_explain_plan(Some(*database_type))) - { - let explain_sql = build_explain_sql(ExplainSqlOptions { + fn estimated_plan_strategy_matches_capability_sql_format_and_acquisition() { + use EstimatedPlanAcquisition::{DriverNative, GeneratedSql, SqlServerShowPlanSession}; + use EstimatedPlanFormat::{Json, Text, Xml}; + + let cases = [ + (DatabaseType::Mysql, "EXPLAIN FORMAT=JSON SELECT 1", Json, GeneratedSql), + (DatabaseType::Doris, "EXPLAIN SELECT 1", Text, GeneratedSql), + (DatabaseType::Postgres, "EXPLAIN (FORMAT JSON) SELECT 1", Json, GeneratedSql), + (DatabaseType::Questdb, "EXPLAIN SELECT 1", Text, GeneratedSql), + (DatabaseType::Dameng, "EXPLAIN SELECT 1", Text, DriverNative), + (DatabaseType::Oracle, "EXPLAIN PLAN FOR SELECT 1", Text, DriverNative), + (DatabaseType::OceanbaseOracle, "EXPLAIN FORMAT=JSON SELECT 1", Json, GeneratedSql), + ( + DatabaseType::SqlServer, + "SET SHOWPLAN_XML ON;\nGO\nSELECT 1\nGO\nSET SHOWPLAN_XML OFF;", + Xml, + SqlServerShowPlanSession, + ), + ]; + + for (database_type, expected_sql, expected_format, expected_acquisition) in cases { + let strategy = estimated_plan_strategy(Some(database_type)) + .unwrap_or_else(|| panic!("{database_type:?} must have a complete Estimated Plan strategy")); + assert!(supports_explain_plan(Some(database_type)), "{database_type:?}"); + assert_eq!(strategy.acquisition(), expected_acquisition, "{database_type:?}"); + assert_eq!(strategy.format(), expected_format, "{database_type:?}"); + assert_eq!(estimated_plan_format(Some(database_type)), expected_format, "{database_type:?}"); + + let result = build_explain_sql(ExplainSqlOptions { database_type: Some(database_type), format: None, analyze: None, sql: "SELECT 1".to_string(), - }) - .sql - .expect("a supported dialect always builds EXPLAIN SQL"); - - match estimated_plan_format(Some(database_type)) { - EstimatedPlanFormat::Json => assert!( - explain_sql.contains("FORMAT JSON") || explain_sql.contains("FORMAT=JSON"), - "{database_type:?} declares a JSON plan but generated '{explain_sql}'" - ), - EstimatedPlanFormat::Xml => assert!( - explain_sql.contains("SHOWPLAN_XML"), - "{database_type:?} declares an XML plan but generated '{explain_sql}'" - ), - EstimatedPlanFormat::Text => assert!( - explain_sql.starts_with("EXPLAIN ") && !explain_sql.contains("FORMAT"), - "{database_type:?} declares a text plan but generated '{explain_sql}'" - ), - } + }); + assert!(result.ok, "{database_type:?}: {:?}", result.reason); + assert_eq!(result.sql.as_deref(), Some(expected_sql), "{database_type:?}"); } - assert_eq!(estimated_plan_format(None), EstimatedPlanFormat::Json); - assert_eq!(estimated_plan_format(Some(DatabaseType::Dameng)), EstimatedPlanFormat::Text); - assert_eq!(estimated_plan_format(Some(DatabaseType::Questdb)), EstimatedPlanFormat::Text); - assert_eq!(estimated_plan_format(Some(DatabaseType::Doris)), EstimatedPlanFormat::Text); - assert_eq!(estimated_plan_format(Some(DatabaseType::Oracle)), EstimatedPlanFormat::Text); - assert_eq!(estimated_plan_format(Some(DatabaseType::SqlServer)), EstimatedPlanFormat::Xml); - assert_eq!(estimated_plan_format(Some(DatabaseType::Mysql)), EstimatedPlanFormat::Json); + for database_type in [ + None, + Some(DatabaseType::Redis), + Some(DatabaseType::MongoDb), + Some(DatabaseType::Gaussdb), + Some(DatabaseType::StarRocks), + ] { + assert!(estimated_plan_strategy(database_type).is_none(), "{database_type:?}"); + assert!(!supports_explain_plan(database_type), "{database_type:?}"); + assert_eq!( + build_explain_sql(ExplainSqlOptions { + database_type, + format: None, + analyze: None, + sql: "SELECT 1".to_string(), + }) + .reason + .as_deref(), + Some("unsupported"), + "{database_type:?}" + ); + } + + // Preserve the legacy fallback for callers that ask for a format without + // first checking support; it does not enable plan capability. + assert_eq!(estimated_plan_format(None), Json); + assert_eq!(estimated_plan_format(Some(DatabaseType::Redis)), Json); } #[test] @@ -1697,6 +1785,26 @@ mod tests { ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) } ); + for sql in [ + "DROP TABLE users;", + "DELETE FROM users;", + "INSERT INTO users (id) VALUES (1);", + "UPDATE users SET name = 'x';", + "SELECT 1; DROP TABLE users;", + "CREATE INDEX idx ON users (id);", + ] { + assert_eq!( + build_explain_sql(ExplainSqlOptions { + database_type: Some(DatabaseType::Mysql), + format: None, + analyze: None, + sql: sql.to_string(), + }), + ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }, + "{sql} must remain blocked by the explain safety gate" + ); + } + assert_eq!( build_explain_sql(ExplainSqlOptions { database_type: Some(DatabaseType::Mysql),