mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
refactor(plan): converge estimated plan capability and strategy
This commit is contained in:
@@ -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<String, String> {
|
||||
/// 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<PlanPayload, String> {
|
||||
let mut format = estimated_plan_format(Some(database_type));
|
||||
fn finalize_plan_payload(plan_format: EstimatedPlanFormat, text: String) -> Result<PlanPayload, String> {
|
||||
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, "<ShowPlanXML><BatchSequence/></ShowPlanXML>".to_string())
|
||||
finalize_plan_payload(EstimatedPlanFormat::Xml, "<ShowPlanXML><BatchSequence/></ShowPlanXML>".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(xml.format, EstimatedPlanFormat::Xml);
|
||||
assert_eq!(xml.raw_plan, Value::String("<ShowPlanXML><BatchSequence/></ShowPlanXML>".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::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DatabaseType>) -> 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<DatabaseType>) -> Option<EstimatedPlanStrategy> {
|
||||
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<DatabaseType>) -> EstimatedPlanFormat {
|
||||
estimated_plan_strategy(database_type).map_or(EstimatedPlanFormat::Json, EstimatedPlanStrategy::format)
|
||||
}
|
||||
|
||||
pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> 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),
|
||||
|
||||
Reference in New Issue
Block a user