Files
dbx/crates/dbx-core/src/db/mysql.rs
T

5749 lines
220 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use futures::StreamExt;
use mysql_async::consts::ColumnType;
use mysql_async::prelude::*;
use percent_encoding::percent_decode_str;
use rust_decimal::Decimal;
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use std::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType};
use crate::schema::{table_name_filter_matches, TableNameFilter};
use crate::sql::{starts_with_executable_sql_keyword, starts_with_executable_sql_keyword_for_database};
use crate::types::{
ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode,
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo,
ForeignKeyInfo, IndexInfo, ObjectInfo, ObjectStatistics, QueryResult, TableInfo, TriggerInfo,
};
use super::file_validator::validate_file_path;
pub type MySqlPool = mysql_async::Pool;
const MYSQL_TCP_KEEPALIVE_MS: u32 = 30_000;
#[derive(Clone, Copy, Debug, Default)]
pub struct MySqlQueryDialect {
supports_admin_show_results: bool,
}
impl MySqlQueryDialect {
pub fn for_connection(db_type: DatabaseType, driver_profile: Option<&str>) -> Self {
let profile = driver_profile.map(str::to_ascii_lowercase);
Self {
supports_admin_show_results: matches!(
db_type,
DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch
) || profile
.as_deref()
.is_some_and(|profile| matches!(profile, "doris" | "selectdb" | "starrocks" | "manticoresearch")),
}
}
}
pub enum MySqlQueryStreamItem {
Columns { columns: Vec<String>, column_types: Vec<String> },
Row(Vec<serde_json::Value>),
}
fn quote_value(s: &str) -> String {
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
}
pub(super) fn quote_identifier(s: &str) -> String {
format!("`{}`", s.replace('`', "``"))
}
fn quote_table_ref(database: &str, table: &str) -> String {
if database.trim().is_empty() {
quote_identifier(table)
} else {
format!("{}.{}", quote_identifier(database), quote_identifier(table))
}
}
fn row_get<T, I>(row: &mysql_async::Row, index: I) -> Option<T>
where
T: mysql_async::prelude::FromValue,
I: mysql_async::prelude::ColumnIndex,
{
row.get_opt::<T, I>(index).and_then(|result| result.ok())
}
/// 字节转 String:合法 UTF-8(绝大多数场景)时直接复用入参缓冲零拷贝,
/// 仅在非法序列时退化为 lossy 替换。from_utf8_lossy(&b).to_string() 即使
/// 对合法输入也会多一次分配+拷贝。
pub(super) fn bytes_to_string_lossy(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned())
}
pub(super) fn get_str(row: &mysql_async::Row, idx: usize) -> String {
row_get::<String, _>(row, idx)
.or_else(|| row_get::<Vec<u8>, _>(row, idx).map(bytes_to_string_lossy))
.unwrap_or_default()
}
pub(super) fn get_str_by_name(row: &mysql_async::Row, name: &str) -> String {
row_get::<String, _>(row, name)
.or_else(|| row_get::<Vec<u8>, _>(row, name).map(bytes_to_string_lossy))
.unwrap_or_default()
}
pub(super) fn get_opt_str(row: &mysql_async::Row, name: &str) -> Option<String> {
row_get::<String, _>(row, name).or_else(|| row_get::<Vec<u8>, _>(row, name).map(bytes_to_string_lossy))
}
/// First non-empty string value among the named columns (e.g. Doris `CatalogName`
/// vs StarRocks `Catalog`). Returns an empty string when none of the columns
/// are present or all are empty.
pub(super) fn first_nonempty_str_by_name(row: &mysql_async::Row, names: &[&str]) -> String {
for name in names {
let value = get_str_by_name(row, name);
if !value.is_empty() {
return value;
}
}
String::new()
}
fn nonblank(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
async fn query_first_nonblank_string(conn: &mut mysql_async::Conn, sql: &str) -> Option<String> {
match conn.query_first::<String, _>(sql).await {
Ok(Some(value)) => nonblank(value),
Ok(None) => None,
Err(error) => {
log::debug!("Failed to read optional MySQL database information with `{sql}`: {error}");
None
}
}
}
pub async fn database_connection_info(
pool: &MySqlPool,
product_name: impl Into<String>,
) -> Result<DatabaseConnectionInfo, String> {
let product_name = nonblank(product_name.into()).unwrap_or_else(|| "MySQL".to_string());
let mut conn = get_conn_with_health_check(pool).await?;
Ok(DatabaseConnectionInfo {
product_name: Some(product_name),
product_version: query_first_nonblank_string(&mut conn, "SELECT VERSION()").await,
current_database: query_first_nonblank_string(&mut conn, "SELECT COALESCE(DATABASE(), '')").await,
server_comment: query_first_nonblank_string(&mut conn, "SELECT @@version_comment").await,
server_charset: query_first_nonblank_string(&mut conn, "SELECT @@character_set_server").await,
server_collation: query_first_nonblank_string(&mut conn, "SELECT @@collation_server").await,
..DatabaseConnectionInfo::default()
})
}
pub fn protocol_product_name(config: &ConnectionConfig) -> String {
config.driver_label.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string).unwrap_or_else(
|| match config.db_type {
DatabaseType::Doris => "Doris".to_string(),
DatabaseType::StarRocks => "StarRocks".to_string(),
DatabaseType::ManticoreSearch => "Manticore Search".to_string(),
_ => "MySQL".to_string(),
},
)
}
fn get_opt_metadata_string(row: &mysql_async::Row, name: &str) -> Option<String> {
get_opt_str(row, name)
.or_else(|| row_get::<NaiveDateTime, _>(row, name).map(|value| value.to_string()))
.or_else(|| row_get::<NaiveDate, _>(row, name).map(|value| value.to_string()))
.or_else(|| row_get::<NaiveTime, _>(row, name).map(|value| value.to_string()))
}
fn numeric_metadata_u64_to_i32(value: Option<u64>) -> Option<i32> {
value.and_then(|v| i32::try_from(v).ok())
}
fn numeric_metadata_i64_to_i32(value: Option<i64>) -> Option<i32> {
value.and_then(|v| i32::try_from(v).ok())
}
fn numeric_metadata_str_to_i32(value: Option<String>) -> Option<i32> {
value.and_then(|v| v.parse::<i64>().ok()).and_then(|v| i32::try_from(v).ok())
}
fn get_opt_i32(row: &mysql_async::Row, name: &str) -> Option<i32> {
row_get::<i32, _>(row, name)
.or_else(|| numeric_metadata_i64_to_i32(row_get::<i64, _>(row, name)))
.or_else(|| numeric_metadata_u64_to_i32(row_get::<u64, _>(row, name)))
.or_else(|| numeric_metadata_str_to_i32(row_get::<String, _>(row, name)))
.or_else(|| {
row_get::<Vec<u8>, _>(row, name)
.and_then(|b| String::from_utf8(b).ok())
.and_then(|v| numeric_metadata_str_to_i32(Some(v)))
})
}
fn get_opt_i64(row: &mysql_async::Row, name: &str) -> Option<i64> {
row_get::<i64, _>(row, name)
.or_else(|| row_get::<u64, _>(row, name).and_then(|value| i64::try_from(value).ok()))
.or_else(|| row_get::<String, _>(row, name).and_then(|value| value.parse::<i64>().ok()))
.or_else(|| {
row_get::<Vec<u8>, _>(row, name)
.and_then(|b| String::from_utf8(b).ok())
.and_then(|value| value.parse::<i64>().ok())
})
}
#[cfg(test)]
fn mysql_datetime_to_string(value: NaiveDateTime) -> String {
value.to_string()
}
#[cfg(test)]
fn is_mysql_lossless_integer_type(type_name: &str) -> bool {
let upper_type = type_name.to_uppercase();
upper_type.contains("BIGINT") || upper_type.contains("LARGEINT")
}
fn is_lossless_integer_column(column: &mysql_async::Column) -> bool {
matches!(column.column_type(), ColumnType::MYSQL_TYPE_LONGLONG | ColumnType::MYSQL_TYPE_NEWDECIMAL)
}
fn is_mysql_binary_charset(column: &mysql_async::Column) -> bool {
column.character_set() == 63
}
fn is_mysql_blob_column(column: &mysql_async::Column) -> bool {
is_mysql_binary_charset(column)
&& matches!(
column.column_type(),
ColumnType::MYSQL_TYPE_BLOB
| ColumnType::MYSQL_TYPE_LONG_BLOB
| ColumnType::MYSQL_TYPE_MEDIUM_BLOB
| ColumnType::MYSQL_TYPE_TINY_BLOB
)
}
fn is_mysql_binary_string_column(column: &mysql_async::Column) -> bool {
is_mysql_binary_charset(column)
&& matches!(
column.column_type(),
ColumnType::MYSQL_TYPE_STRING | ColumnType::MYSQL_TYPE_VAR_STRING | ColumnType::MYSQL_TYPE_VARCHAR
)
}
fn mysql_printable_binary_preview(bytes: &[u8]) -> Option<String> {
let trimmed = bytes.strip_suffix(&[0]).map_or(bytes, |mut value| {
while let Some(rest) = value.strip_suffix(&[0]) {
value = rest;
}
value
});
if trimmed.is_empty() {
return Some(String::new());
}
let text = std::str::from_utf8(trimmed).ok()?;
text.chars().all(|ch| !ch.is_control() || matches!(ch, '\t' | '\n' | '\r')).then(|| text.to_string())
}
fn mysql_blob_preview(bytes: &[u8], label: &str) -> serde_json::Value {
if label == "BLOB" {
return super::binary_value_to_json(bytes);
}
serde_json::Value::String(format!("({label}) {} bytes", bytes.len()))
}
fn mysql_bit_value_to_string(bytes: &[u8], column: &mysql_async::Column) -> String {
let bit_len = column.column_length();
if bit_len > 1 {
let total_bits = bytes.len() * 8;
let mut bits = String::with_capacity(total_bits);
for byte in bytes {
bits.push_str(&format!("{byte:08b}"));
}
let start = bits.len().saturating_sub(bit_len as usize);
return bits[start..].to_string();
}
let val = bytes.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64);
val.to_string()
}
fn mysql_bytes_to_json(bytes: Vec<u8>, column: &mysql_async::Column) -> serde_json::Value {
if is_mysql_blob_column(column) {
return mysql_blob_preview(&bytes, "BLOB");
}
if is_mysql_binary_string_column(column) {
return mysql_printable_binary_preview(&bytes)
.map(serde_json::Value::String)
.unwrap_or_else(|| super::binary_value_to_json(&bytes));
}
serde_json::Value::String(bytes_to_string_lossy(bytes))
}
/// Map a MySQL column to a user-facing type name for the result-grid header.
/// Returns the bare lowercase type name (no length/precision/signedness), which
/// is enough for display; unknown variants fall back to a lowercased debug name.
///
/// MySQL's wire protocol uses the same `MYSQL_TYPE_*BLOB` codes for TEXT and BLOB
/// families. Binary charset (63) means BLOB; any other charset means TEXT. Value
/// decoding already follows that rule — the header type must match, or TEXT
/// columns flash as `blob` until table metadata arrives.
pub(crate) fn mysql_column_type_name(column: &mysql_async::Column) -> String {
use mysql_async::consts::ColumnType::*;
let ty = column.column_type();
let flags = column.flags();
let binary = is_mysql_binary_charset(column);
match ty {
MYSQL_TYPE_TINY => "tinyint",
MYSQL_TYPE_SHORT => "smallint",
MYSQL_TYPE_INT24 => "mediumint",
MYSQL_TYPE_LONG => "int",
MYSQL_TYPE_LONGLONG => "bigint",
MYSQL_TYPE_FLOAT => "float",
MYSQL_TYPE_DOUBLE => "double",
MYSQL_TYPE_DECIMAL | MYSQL_TYPE_NEWDECIMAL => "decimal",
MYSQL_TYPE_BIT => "bit",
MYSQL_TYPE_YEAR => "year",
MYSQL_TYPE_DATE | MYSQL_TYPE_NEWDATE => "date",
MYSQL_TYPE_TIME | MYSQL_TYPE_TIME2 => "time",
MYSQL_TYPE_DATETIME | MYSQL_TYPE_DATETIME2 => "datetime",
MYSQL_TYPE_TIMESTAMP | MYSQL_TYPE_TIMESTAMP2 => "timestamp",
MYSQL_TYPE_JSON => "json",
MYSQL_TYPE_ENUM => "enum",
MYSQL_TYPE_SET => "set",
MYSQL_TYPE_TINY_BLOB => {
if binary {
"tinyblob"
} else {
"tinytext"
}
}
MYSQL_TYPE_MEDIUM_BLOB => {
if binary {
"mediumblob"
} else {
"mediumtext"
}
}
MYSQL_TYPE_LONG_BLOB => {
if binary {
"longblob"
} else {
"longtext"
}
}
MYSQL_TYPE_BLOB => {
if binary {
"blob"
} else {
"text"
}
}
MYSQL_TYPE_VARCHAR | MYSQL_TYPE_VAR_STRING => {
if binary {
"varbinary"
} else {
"varchar"
}
}
MYSQL_TYPE_STRING => {
// MySQL reports ENUM/SET result columns as STRING plus a flag,
// rather than using the dedicated protocol type codes.
if flags.contains(mysql_async::consts::ColumnFlags::ENUM_FLAG) {
"enum"
} else if flags.contains(mysql_async::consts::ColumnFlags::SET_FLAG) {
"set"
} else if binary {
"binary"
} else {
"char"
}
}
MYSQL_TYPE_GEOMETRY => "geometry",
MYSQL_TYPE_NULL => "null",
other => return format!("{:?}", other).to_lowercase(),
}
.to_string()
}
pub(crate) fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value {
let Some(column) = row.columns_ref().get(idx) else {
return serde_json::Value::Null;
};
let Some(value) = row.as_ref(idx) else {
return serde_json::Value::Null;
};
if matches!(value, mysql_async::Value::NULL) {
return serde_json::Value::Null;
}
if is_mysql_binary_string_column(column) {
return row_get::<Vec<u8>, _>(row, idx)
.map(|bytes| mysql_bytes_to_json(bytes, column))
.unwrap_or(serde_json::Value::Null);
}
match column.column_type() {
ColumnType::MYSQL_TYPE_JSON => {
if let Some(v) = row_get::<String, _>(row, idx) {
return serde_json::Value::String(v);
}
}
ColumnType::MYSQL_TYPE_DECIMAL | ColumnType::MYSQL_TYPE_NEWDECIMAL | ColumnType::MYSQL_TYPE_LONGLONG => {
if is_lossless_integer_column(column) {
return row
.get_opt::<String, usize>(idx)
.and_then(|result| result.ok())
.map(serde_json::Value::String)
.or_else(|| {
row_get::<Decimal, _>(row, idx).map(|v: Decimal| serde_json::Value::String(v.to_string()))
})
.or_else(|| row_get::<i64, _>(row, idx).map(|v| serde_json::Value::String(v.to_string())))
.or_else(|| row_get::<u64, _>(row, idx).map(|v| serde_json::Value::String(v.to_string())))
.or_else(|| row_get::<Vec<u8>, _>(row, idx).map(|bytes| mysql_bytes_to_json(bytes, column)))
.unwrap_or(serde_json::Value::Null);
}
return row
.get_opt::<Decimal, usize>(idx)
.and_then(|result| result.ok())
.map(|v: Decimal| serde_json::Value::String(v.to_string()))
.unwrap_or(serde_json::Value::Null);
}
ColumnType::MYSQL_TYPE_BIT => {
return row_get::<Vec<u8>, _>(row, idx)
.map(|bytes| serde_json::Value::String(mysql_bit_value_to_string(&bytes, column)))
.unwrap_or(serde_json::Value::Null);
}
ColumnType::MYSQL_TYPE_BLOB
| ColumnType::MYSQL_TYPE_LONG_BLOB
| ColumnType::MYSQL_TYPE_MEDIUM_BLOB
| ColumnType::MYSQL_TYPE_TINY_BLOB
| ColumnType::MYSQL_TYPE_GEOMETRY => {
return row_get::<Vec<u8>, _>(row, idx)
.map(|bytes| {
if matches!(column.column_type(), ColumnType::MYSQL_TYPE_GEOMETRY) {
// MySQL prefixes geometry WKB with a 4-byte SRID.
// Strip it before passing to the WKB parser.
let wkb = if bytes.len() >= 4 { &bytes[4..] } else { &bytes };
super::wkb::wkb_to_wkt(wkb)
.map(serde_json::Value::String)
.unwrap_or_else(|| super::binary_value_to_json(&bytes))
} else {
mysql_bytes_to_json(bytes, column)
}
})
.unwrap_or(serde_json::Value::Null);
}
ColumnType::MYSQL_TYPE_TIMESTAMP
| ColumnType::MYSQL_TYPE_TIMESTAMP2
| ColumnType::MYSQL_TYPE_DATETIME
| ColumnType::MYSQL_TYPE_DATETIME2
| ColumnType::MYSQL_TYPE_DATE
| ColumnType::MYSQL_TYPE_TIME
| ColumnType::MYSQL_TYPE_TIME2
| ColumnType::MYSQL_TYPE_NEWDATE => {
if let Some(value) = mysql_temporal_value_to_json(
column.column_type(),
row_get::<NaiveDateTime, _>(row, idx),
row_get::<NaiveDate, _>(row, idx),
row_get::<NaiveTime, _>(row, idx),
) {
return value;
}
}
_ => {}
}
row_get::<String, _>(row, idx)
.map(|s| serde_json::Value::String(fix_potential_double_encoding(&s)))
.or_else(|| row_get::<i64, _>(row, idx).map(super::safe_i64_to_json))
.or_else(|| row_get::<u64, _>(row, idx).map(super::safe_u64_to_json))
.or_else(|| row_get::<i32, _>(row, idx).map(|v| serde_json::Value::Number(v.into())))
.or_else(|| row_get::<i16, _>(row, idx).map(|v| serde_json::Value::Number(v.into())))
.or_else(|| {
row_get::<f64, _>(row, idx).map(|v| {
serde_json::Number::from_f64(v).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null)
})
})
.or_else(|| row_get::<bool, _>(row, idx).map(serde_json::Value::Bool))
.or_else(|| row_get::<Vec<u8>, _>(row, idx).map(|bytes| mysql_bytes_to_json(bytes, column)))
.unwrap_or(serde_json::Value::Null)
}
fn mysql_temporal_value_to_json(
column_type: ColumnType,
datetime: Option<NaiveDateTime>,
date: Option<NaiveDate>,
time: Option<NaiveTime>,
) -> Option<serde_json::Value> {
let value = match column_type {
ColumnType::MYSQL_TYPE_DATE | ColumnType::MYSQL_TYPE_NEWDATE => {
date.map(|value| value.to_string()).or_else(|| datetime.map(|value| value.date().to_string()))?
}
ColumnType::MYSQL_TYPE_TIME | ColumnType::MYSQL_TYPE_TIME2 => time.map(|value| value.to_string())?,
ColumnType::MYSQL_TYPE_TIMESTAMP
| ColumnType::MYSQL_TYPE_TIMESTAMP2
| ColumnType::MYSQL_TYPE_DATETIME
| ColumnType::MYSQL_TYPE_DATETIME2 => datetime
.map(|value| value.to_string())
.or_else(|| date.map(|value| value.to_string()))
.or_else(|| time.map(|value| value.to_string()))?,
_ => return None,
};
Some(serde_json::Value::String(value))
}
pub async fn connect(url: &str, fallback_timeout: Duration) -> Result<MySqlPool, String> {
connect_with_ca_cert(url, None, fallback_timeout).await
}
pub async fn connect_with_ca_cert(
url: &str,
ca_cert_path: Option<&str>,
fallback_timeout: Duration,
) -> Result<MySqlPool, String> {
connect_with_ca_cert_and_pool_limit(url, ca_cert_path, fallback_timeout, 10).await
}
pub async fn connect_with_ca_cert_and_pool_limit(
url: &str,
ca_cert_path: Option<&str>,
fallback_timeout: Duration,
max_connections: usize,
) -> Result<MySqlPool, String> {
connect_with_ca_cert_pool_limit_and_idle(url, ca_cert_path, fallback_timeout, max_connections, None).await
}
pub async fn connect_with_ca_cert_pool_limit_and_idle(
url: &str,
ca_cert_path: Option<&str>,
fallback_timeout: Duration,
max_connections: usize,
idle_timeout_secs: Option<u64>,
) -> Result<MySqlPool, String> {
connect_with_ca_cert_pool_limit_idle_and_setup(
url,
ca_cert_path,
fallback_timeout,
max_connections,
idle_timeout_secs,
&[],
)
.await
}
pub async fn connect_with_ca_cert_pool_limit_idle_and_setup(
url: &str,
ca_cert_path: Option<&str>,
fallback_timeout: Duration,
max_connections: usize,
idle_timeout_secs: Option<u64>,
extra_setup_queries: &[String],
) -> Result<MySqlPool, String> {
connect_with_ca_cert_pool_limit_idle_and_setup_database(
url,
ca_cert_path,
fallback_timeout,
max_connections,
idle_timeout_secs,
None,
extra_setup_queries,
)
.await
}
pub async fn connect_with_ca_cert_pool_limit_idle_and_setup_database(
url: &str,
ca_cert_path: Option<&str>,
fallback_timeout: Duration,
max_connections: usize,
idle_timeout_secs: Option<u64>,
setup_database: Option<&str>,
extra_setup_queries: &[String],
) -> Result<MySqlPool, String> {
connect_with_ca_cert_pool_limit_idle_setup_database_with_mode(
url,
ca_cert_path,
fallback_timeout,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
MySqlSetupMode::Standard,
)
.await
}
pub async fn connect_compatible_with_ca_cert_pool_limit_idle_and_setup(
url: &str,
ca_cert_path: Option<&str>,
fallback_timeout: Duration,
max_connections: usize,
idle_timeout_secs: Option<u64>,
extra_setup_queries: &[String],
) -> Result<MySqlPool, String> {
connect_compatible_with_ca_cert_pool_limit_idle_and_setup_database(
url,
ca_cert_path,
fallback_timeout,
max_connections,
idle_timeout_secs,
None,
extra_setup_queries,
)
.await
}
pub async fn connect_compatible_with_ca_cert_pool_limit_idle_and_setup_database(
url: &str,
ca_cert_path: Option<&str>,
fallback_timeout: Duration,
max_connections: usize,
idle_timeout_secs: Option<u64>,
setup_database: Option<&str>,
extra_setup_queries: &[String],
) -> Result<MySqlPool, String> {
connect_with_ca_cert_pool_limit_idle_setup_database_with_mode(
url,
ca_cert_path,
fallback_timeout,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
MySqlSetupMode::Compatible,
)
.await
}
async fn connect_with_ca_cert_pool_limit_idle_setup_database_with_mode(
url: &str,
ca_cert_path: Option<&str>,
fallback_timeout: Duration,
max_connections: usize,
idle_timeout_secs: Option<u64>,
setup_database: Option<&str>,
extra_setup_queries: &[String],
setup_mode: MySqlSetupMode,
) -> Result<MySqlPool, String> {
let timeout = super::parse_connect_timeout_with_fallback(url, fallback_timeout);
let mut retry_url = url.to_string();
let mut retry_ca_cert_path = ca_cert_path;
let mut result = connect_pool_attempt(
url,
ca_cert_path,
timeout,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
setup_mode,
MySqlEofMode::Deprecate,
)
.await;
if result.as_ref().err().is_some_and(|error| mysql_error_should_retry_without_ssl(error)) {
if let Some(fallback_url) = ssl_fallback_url(url) {
log::info!("SSL handshake failed, retrying with ssl-mode=disabled");
retry_url = fallback_url;
retry_ca_cert_path = None;
result = connect_pool_attempt(
&retry_url,
None,
timeout,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
setup_mode,
MySqlEofMode::Deprecate,
)
.await;
}
}
if result.as_ref().err().is_some_and(|error| mysql_error_should_retry_with_legacy_eof(error)) {
log::info!("MySQL proxy returned legacy EOF packets; retrying with CLIENT_DEPRECATE_EOF disabled");
return connect_pool_attempt(
&retry_url,
retry_ca_cert_path,
timeout,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
setup_mode,
MySqlEofMode::Legacy,
)
.await;
}
result
}
#[allow(clippy::too_many_arguments)]
async fn connect_pool_attempt(
url: &str,
ca_cert_path: Option<&str>,
timeout: Duration,
max_connections: usize,
idle_timeout_secs: Option<u64>,
setup_database: Option<&str>,
extra_setup_queries: &[String],
setup_mode: MySqlSetupMode,
eof_mode: MySqlEofMode,
) -> Result<MySqlPool, String> {
let pool = create_pool(
url,
ca_cert_path,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
setup_mode,
eof_mode,
)?;
verify_pool_connection_with_setup_fallback(
pool,
timeout,
url,
ca_cert_path,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
setup_mode,
eof_mode,
)
.await
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct MySqlTlsFiles {
sslcert: Option<String>,
sslkey: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MySqlSetupMode {
Standard,
Compatible,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MySqlEofMode {
Deprecate,
Legacy,
}
impl MySqlEofMode {
fn deprecate_eof(self) -> bool {
self == Self::Deprecate
}
}
const MYSQL_GROUP_CONCAT_MAX_LEN: u64 = 1_048_576;
impl MySqlSetupMode {
fn group_concat_max_len_query(self) -> Option<String> {
match self {
Self::Standard => Some(format!("SET SESSION group_concat_max_len = {MYSQL_GROUP_CONCAT_MAX_LEN}")),
Self::Compatible => None,
}
}
}
async fn verify_pool_connection_with_setup_fallback(
pool: MySqlPool,
timeout: Duration,
url: &str,
ca_cert_path: Option<&str>,
max_connections: usize,
idle_timeout_secs: Option<u64>,
setup_database: Option<&str>,
extra_setup_queries: &[String],
setup_mode: MySqlSetupMode,
eof_mode: MySqlEofMode,
) -> Result<MySqlPool, String> {
match verify_pool_connection(&pool, timeout).await {
Ok(()) => Ok(pool),
Err(err) => {
let Some(fallback_mode) = mysql_group_concat_setup_fallback_mode(setup_mode, &err) else {
return Err(err);
};
log::info!(
"MySQL server rejected optional group_concat_max_len setup; retrying with {fallback_mode:?} mode"
);
let fallback_pool = create_pool(
url,
ca_cert_path,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
fallback_mode,
eof_mode,
)?;
verify_pool_connection(&fallback_pool, timeout).await.map(|_| fallback_pool)
}
}
}
fn mysql_group_concat_setup_fallback_mode(setup_mode: MySqlSetupMode, error: &str) -> Option<MySqlSetupMode> {
if setup_mode != MySqlSetupMode::Standard {
return None;
}
let lower = error.to_ascii_lowercase();
let setup_query_rejected = lower.contains("1193")
|| lower.contains("unknown system variable")
|| lower.contains("syntax error")
|| lower.contains("not supported");
let sphinxql_setup_query_rejected = lower.contains("sphinxql")
&& lower.contains("only 0 and 1 could be used as boolean values")
&& lower.contains(&format!("near '{MYSQL_GROUP_CONCAT_MAX_LEN}'"));
// Some MySQL gateways omit the variable name and report session-variable
// changes as a forbidden global-variable operation.
let gateway_session_variable_rejected =
lower.contains("error 10192 (hy000)") && lower.contains("set global variables is forbidden");
if (lower.contains("group_concat_max_len") && setup_query_rejected)
|| sphinxql_setup_query_rejected
|| gateway_session_variable_rejected
{
return Some(MySqlSetupMode::Compatible);
}
None
}
fn create_pool(
url: &str,
ca_cert_path: Option<&str>,
max_connections: usize,
idle_timeout_secs: Option<u64>,
setup_database: Option<&str>,
extra_setup_queries: &[String],
setup_mode: MySqlSetupMode,
eof_mode: MySqlEofMode,
) -> Result<MySqlPool, String> {
let tls_url = mysql_tls_url(url)?;
let local_infile_paths = mysql_local_infile_paths(&tls_url.url);
let opts =
mysql_async::Opts::from_url(&mysql_async_url(&tls_url.url)).map_err(|e| format!("Invalid MySQL URL: {e}"))?;
let tcp_host = mysql_async_tcp_host(opts.ip_or_hostname()).to_string();
let base_ssl_opts = opts.ssl_opts().cloned();
let max_connections = max_connections.max(1);
// Single-connection pools (max_connections == 1) are client session pools that
// must preserve session state (e.g. TEMPORARY TABLEs) across queries.
// Disable COM_RESET_CONNECTION for these pools to avoid clearing that state.
let inactive_ttl =
idle_timeout_secs.filter(|&s| s >= 30).map(Duration::from_secs).unwrap_or(Duration::from_secs(300));
let pool_opts = mysql_async::PoolOpts::new()
.with_constraints(mysql_async::PoolConstraints::new(1, max_connections).unwrap())
.with_inactive_connection_ttl(inactive_ttl)
.with_reset_connection(max_connections > 1);
let setup_queries = match (setup_database, setup_mode) {
(Some(database), MySqlSetupMode::Standard) => {
mysql_setup_queries_for_database(url, Some(database), extra_setup_queries)
}
(None, MySqlSetupMode::Standard) => mysql_setup_queries(url, extra_setup_queries),
(Some(database), MySqlSetupMode::Compatible) => {
mysql_setup_queries_for_database_with_mode(url, Some(database), extra_setup_queries, setup_mode)
}
(None, MySqlSetupMode::Compatible) => mysql_setup_queries_with_mode(url, extra_setup_queries, setup_mode),
};
let mut builder = mysql_async::OptsBuilder::from_opts(opts)
.ip_or_hostname(tcp_host)
.stmt_cache_size(0)
.prefer_socket(false)
.pool_opts(Some(pool_opts))
.tcp_keepalive(Some(Duration::from_millis(u64::from(MYSQL_TCP_KEEPALIVE_MS))))
.deprecate_eof(eof_mode.deprecate_eof())
.setup(setup_queries);
if let Some(ssl_opts) = mysql_ssl_opts(base_ssl_opts, url, ca_cert_path, &tls_url.files)? {
builder = builder.ssl_opts(ssl_opts);
}
if !local_infile_paths.is_empty() {
// LOCAL INFILE lets the server request a client-side file. Restrict it
// to paths explicitly supplied by the user instead of enabling arbitrary reads.
builder = builder.local_infile_handler(Some(mysql_async::WhiteListFsHandler::new(local_infile_paths)));
}
Ok(MySqlPool::new(builder))
}
fn mysql_async_tcp_host(host: &str) -> &str {
if let Some(inner) = host.strip_prefix('[').and_then(|value| value.strip_suffix(']')) {
// mysql_async preserves IPv6 brackets when converting URL opts into an
// OptsBuilder, but the builder TCP path resolves host strings directly.
if inner.parse::<std::net::Ipv6Addr>().is_ok() {
return inner;
}
}
host
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MySqlTlsUrl {
url: String,
files: MySqlTlsFiles,
}
fn mysql_tls_url(url: &str) -> Result<MySqlTlsUrl, String> {
let Some(query_start) = url.find('?') else {
return Ok(MySqlTlsUrl { url: url.to_string(), files: MySqlTlsFiles::default() });
};
let prefix = &url[..query_start];
let suffix = &url[query_start + 1..];
let (query_string, fragment) = suffix.split_once('#').map_or((suffix, ""), |(query, fragment)| (query, fragment));
let mut files = MySqlTlsFiles::default();
let mut kept_params = Vec::new();
for param in query_string.split('&') {
if param.is_empty() {
continue;
}
let Some((key, value)) = param.split_once('=') else {
kept_params.push(param.to_string());
continue;
};
if mysql_tls_file_param_is(key, "cert") || mysql_tls_file_param_is(key, "key") {
let decoded = percent_decode_str(value)
.decode_utf8()
.map_err(|_| format!("Invalid URL encoding in {key}"))?
.into_owned();
validate_file_path(&decoded, |_| false).map_err(|e| format!("{key}: {e}"))?;
if mysql_tls_file_param_is(key, "cert") {
files.sslcert = Some(decoded);
} else {
files.sslkey = Some(decoded);
}
} else {
kept_params.push(param.to_string());
}
}
let mut sanitized_url = prefix.to_string();
if !kept_params.is_empty() {
sanitized_url.push('?');
sanitized_url.push_str(&kept_params.join("&"));
}
if !fragment.is_empty() {
sanitized_url.push('#');
sanitized_url.push_str(fragment);
}
Ok(MySqlTlsUrl { url: sanitized_url, files })
}
fn mysql_tls_file_param_is(key: &str, target: &str) -> bool {
let normalized = key.to_ascii_lowercase().replace(['-', '_'], "");
normalized == format!("ssl{target}")
}
fn mysql_ssl_opts(
base_ssl_opts: Option<mysql_async::SslOpts>,
url: &str,
ca_cert_path: Option<&str>,
files: &MySqlTlsFiles,
) -> Result<Option<mysql_async::SslOpts>, String> {
let ca_cert_path = ca_cert_path.map(str::trim).filter(|path| !path.is_empty());
let has_client_identity = files.sslcert.as_deref().is_some() || files.sslkey.as_deref().is_some();
if !mysql_url_attempts_ssl(url) && !has_client_identity {
return Ok(None);
}
let mut ssl_opts = base_ssl_opts.unwrap_or_default();
if let Some(ca_cert_path) = ca_cert_path.filter(|_| mysql_url_attempts_ssl(url) || has_client_identity) {
ssl_opts = ssl_opts.with_root_certs(vec![PathBuf::from(ca_cert_path).into()]);
if !mysql_url_verifies_identity(url) {
ssl_opts = ssl_opts.with_danger_skip_domain_validation(true);
}
}
match (files.sslcert.as_deref(), files.sslkey.as_deref()) {
(Some(cert_path), Some(key_path)) => {
ssl_opts = ssl_opts.with_client_identity(Some(mysql_async::ClientIdentity::new(
PathBuf::from(cert_path).into(),
PathBuf::from(key_path).into(),
)));
}
(Some(_), None) => return Err("MySQL ssl-cert requires ssl-key".to_string()),
(None, Some(_)) => return Err("MySQL ssl-key requires ssl-cert".to_string()),
(None, None) => {}
}
Ok(Some(ssl_opts))
}
fn mysql_setup_queries(url: &str, extra_setup_queries: &[String]) -> Vec<String> {
mysql_setup_queries_with_mode(url, extra_setup_queries, MySqlSetupMode::Standard)
}
fn mysql_setup_queries_for_database(
url: &str,
setup_database: Option<&str>,
extra_setup_queries: &[String],
) -> Vec<String> {
mysql_setup_queries_for_database_with_mode(url, setup_database, extra_setup_queries, MySqlSetupMode::Standard)
}
fn mysql_setup_queries_with_mode(url: &str, extra_setup_queries: &[String], setup_mode: MySqlSetupMode) -> Vec<String> {
mysql_setup_queries_for_database_with_mode(url, None, extra_setup_queries, setup_mode)
}
fn mysql_setup_queries_for_database_with_mode(
url: &str,
setup_database: Option<&str>,
extra_setup_queries: &[String],
setup_mode: MySqlSetupMode,
) -> Vec<String> {
let charset = mysql_connection_charset(url).unwrap_or("utf8mb4");
let catalog = mysql_connection_catalog(url);
let database = setup_database.map(ToOwned::to_owned).or_else(|| mysql_connection_database(url));
let mut queries = Vec::new();
if let Some(database) = database.as_deref() {
queries.push(format!("USE {}", quote_identifier(database)));
}
if let Some(time_zone) = mysql_connection_time_zone(url) {
queries.push(format!("SET time_zone = {}", quote_value(&time_zone)));
}
if let Some(session_variables) = mysql_connection_session_variables(url) {
queries.push(session_variables);
}
queries.push(format!("SET NAMES {charset}"));
// MySQL defaults group_concat_max_len to 1024, which silently truncates
// GROUP_CONCAT results. Skip it for MySQL protocol-compatible databases
// such as old StarRocks versions that reject unknown MySQL variables.
if let Some(query) = setup_mode.group_concat_max_len_query() {
queries.push(query);
}
// StarRocks/Doris expose external storage (Paimon, Hive, ...) through a
// catalog. `SET catalog` must run *before* `USE <database>` (the database
// lives in the external catalog and is unknown to the default one).
// mysql_async drains the setup list back-to-front (Vec::pop), so push it
// last to make it execute first. The handshake does not send the database
// as schema (see `mysql_async_url`, which strips the path when a catalog is
// configured), so the connection establishes in the default catalog and
// this setup query is what switches it. The pool re-runs these queries
// after every connection reset, so the catalog stays current.
if let Some(catalog) = catalog.as_deref() {
queries.push(format!("SET catalog = {}", quote_identifier(catalog)));
}
queries.extend(extra_setup_queries.iter().cloned());
queries
}
fn should_enable_explicit_timestamp_defaults(sql: &str) -> bool {
if !starts_with_executable_sql_keyword(sql, &["CREATE", "ALTER"]) {
return false;
}
let lower = sql.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
lower.contains("timestamp") && lower.contains("default null")
}
fn explicit_timestamp_defaults_sql(enabled: bool) -> &'static str {
if enabled {
"SET SESSION explicit_defaults_for_timestamp = ON"
} else {
"SET SESSION explicit_defaults_for_timestamp = OFF"
}
}
async fn enable_explicit_timestamp_defaults_for_query(conn: &mut mysql_async::Conn, sql: &str) -> Option<bool> {
if !should_enable_explicit_timestamp_defaults(sql) {
return None;
}
let previous = match conn.query_first::<u8, _>("SELECT @@SESSION.explicit_defaults_for_timestamp").await {
Ok(Some(value)) => value != 0,
Ok(None) => {
log::debug!("Skipping MySQL explicit timestamp defaults compatibility setting: variable was empty");
return None;
}
Err(err) => {
log::debug!("Skipping MySQL explicit timestamp defaults compatibility setting: {err}");
return None;
}
};
if previous {
return None;
}
if let Err(err) = conn.query_drop(explicit_timestamp_defaults_sql(true)).await {
log::debug!("Skipping MySQL explicit timestamp defaults compatibility setting: {err}");
return None;
}
Some(previous)
}
async fn restore_explicit_timestamp_defaults_for_query(conn: &mut mysql_async::Conn, previous: Option<bool>) {
if let Some(previous) = previous {
if let Err(err) = conn.query_drop(explicit_timestamp_defaults_sql(previous)).await {
log::warn!("Failed to restore MySQL explicit timestamp defaults session setting: {err}");
}
}
}
fn mysql_connection_charset(url: &str) -> Option<&str> {
let (_, query) = url.split_once('?')?;
query.split('&').find_map(|segment| {
let (key, value) = segment.split_once('=')?;
if !key.eq_ignore_ascii_case("charset") {
return None;
}
let value = value.trim();
is_safe_mysql_charset_name(value).then_some(value)
})
}
fn mysql_connection_database(url: &str) -> Option<String> {
let rest = url.strip_prefix("mysql://")?;
let (_, path_and_query) = rest.split_once('/')?;
let path = path_and_query.split(['?', '#']).next().unwrap_or(path_and_query);
let database = path.trim_start_matches('/').split('/').next().unwrap_or("").trim();
if database.is_empty() {
return None;
}
percent_decode_str(database).decode_utf8().ok().map(|value| value.into_owned())
}
/// Extracts an opt-in `catalog=<name>` URL parameter. dbx strips it from the
/// URL before handing it to mysql_async (see `is_dbx_handled_mysql_url_param`)
/// and instead emits `SET catalog = <name>` during connection setup. This is
/// how StarRocks/Doris connections reach an external catalog such as Paimon.
fn mysql_connection_catalog(url: &str) -> Option<String> {
let (_, query) = url.split_once('?')?;
let query = query.split('#').next().unwrap_or(query);
query.split('&').find_map(|segment| {
let (key, value) = segment.split_once('=')?;
if !key.eq_ignore_ascii_case("catalog") {
return None;
}
let value = value.trim();
if value.is_empty() {
return None;
}
percent_decode_str(value).decode_utf8().ok().map(|value| value.into_owned())
})
}
fn mysql_connection_session_variables(url: &str) -> Option<String> {
let (_, query) = url.split_once('?')?;
let query = query.split('#').next().unwrap_or(query);
let value = query.split('&').find_map(|segment| {
let (key, value) = segment.split_once('=')?;
percent_decode_str(key).decode_utf8().ok().filter(|key| key.eq_ignore_ascii_case("sessionVariables"))?;
percent_decode_str(value).decode_utf8().ok().map(|value| value.into_owned())
})?;
let assignments = split_mysql_session_variables(&value);
if assignments.is_empty() {
return None;
}
// Match Connector/J: separators inside strings or expressions are preserved,
// while system variables receive SESSION and user variables keep their @ prefix.
Some(format!(
"SET {}",
assignments
.into_iter()
.map(|assignment| {
if assignment.starts_with('@') {
assignment
} else {
format!("SESSION {assignment}")
}
})
.collect::<Vec<_>>()
.join(",")
))
}
fn mysql_local_infile_paths(url: &str) -> Vec<PathBuf> {
let Some((_, query)) = url.split_once('?') else {
return Vec::new();
};
let query = query.split('#').next().unwrap_or(query);
query
.split('&')
.filter_map(|segment| {
let (key, value) = segment.split_once('=')?;
percent_decode_str(key).decode_utf8().ok().filter(|key| key.eq_ignore_ascii_case("localInfilePath"))?;
let path = percent_decode_str(value).decode_utf8().ok()?.trim().to_string();
(!path.is_empty()).then(|| PathBuf::from(path))
})
.collect()
}
fn split_mysql_session_variables(value: &str) -> Vec<String> {
let chars: Vec<char> = value.chars().collect();
let mut assignments = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut escaped = false;
let mut parenthesis_depth = 0usize;
let mut index = 0usize;
while index < chars.len() {
let ch = chars[index];
if let Some(active_quote) = quote {
current.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == active_quote {
if chars.get(index + 1) == Some(&active_quote) {
current.push(active_quote);
index += 1;
} else {
quote = None;
}
}
index += 1;
continue;
}
match ch {
'\'' | '"' => {
quote = Some(ch);
current.push(ch);
}
'(' => {
parenthesis_depth += 1;
current.push(ch);
}
')' => {
parenthesis_depth = parenthesis_depth.saturating_sub(1);
current.push(ch);
}
',' | ';' if parenthesis_depth == 0 => {
let assignment = current.trim();
if !assignment.is_empty() {
assignments.push(assignment.to_string());
}
current.clear();
}
_ => current.push(ch),
}
index += 1;
}
let assignment = current.trim();
if !assignment.is_empty() {
assignments.push(assignment.to_string());
}
assignments
}
fn is_safe_mysql_charset_name(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
}
fn mysql_connection_time_zone(url: &str) -> Option<String> {
let (_, query) = url.split_once('?')?;
let mut jdbc_time_zone: Option<String> = None;
let mut go_location: Option<String> = None;
for segment in query.split('&') {
let Some((raw_key, raw_value)) = segment.split_once('=') else {
continue;
};
let key = percent_decode_str(raw_key).decode_utf8_lossy();
let value = percent_decode_str(raw_value).decode_utf8_lossy().trim().to_string();
if value.is_empty() {
continue;
}
if key.eq_ignore_ascii_case("time_zone")
|| key.eq_ignore_ascii_case("time-zone")
|| key.eq_ignore_ascii_case("timezone")
{
if let Some(value) = normalize_mysql_time_zone_value(&value) {
return Some(value);
}
} else if key.eq_ignore_ascii_case("connectionTimeZone") || key.eq_ignore_ascii_case("serverTimezone") {
if jdbc_time_zone.is_none() {
jdbc_time_zone = normalize_mysql_time_zone_value(&value);
}
} else if key.eq_ignore_ascii_case("loc") && go_location.is_none() {
go_location = normalize_mysql_time_zone_value(&value);
}
}
jdbc_time_zone.or(go_location)
}
fn normalize_mysql_time_zone_value(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
if value.eq_ignore_ascii_case("local") {
return Some(local_mysql_time_zone_offset());
}
if value.eq_ignore_ascii_case("utc") || value.eq_ignore_ascii_case("z") {
return Some("+00:00".to_string());
}
if value.eq_ignore_ascii_case("system") {
return Some("SYSTEM".to_string());
}
if let Some(offset) = normalize_mysql_time_zone_offset(value) {
return Some(offset);
}
if let Some(offset_part) = value
.strip_prefix("GMT")
.or_else(|| value.strip_prefix("gmt"))
.or_else(|| value.strip_prefix("UTC"))
.or_else(|| value.strip_prefix("utc"))
{
if let Some(offset) = normalize_mysql_time_zone_offset(offset_part) {
return Some(offset);
}
}
is_safe_mysql_time_zone_name(value).then(|| value.to_string())
}
fn normalize_mysql_time_zone_offset(value: &str) -> Option<String> {
let value = value.trim();
let (sign, rest) = match value.as_bytes().first().copied()? {
b'+' => ('+', &value[1..]),
b'-' => ('-', &value[1..]),
_ => return None,
};
let (hours, minutes) =
if let Some((hours, minutes)) = rest.split_once(':') { (hours, minutes) } else { (rest, "0") };
if hours.is_empty() || hours.len() > 2 || minutes.is_empty() || minutes.len() > 2 {
return None;
}
let hours = hours.parse::<u8>().ok()?;
let minutes = minutes.parse::<u8>().ok()?;
if hours > 14 || minutes > 59 || (hours == 14 && minutes != 0) {
return None;
}
Some(format!("{sign}{hours:02}:{minutes:02}"))
}
fn local_mysql_time_zone_offset() -> String {
let seconds = chrono::Local::now().offset().local_minus_utc();
let sign = if seconds < 0 { '-' } else { '+' };
let seconds = seconds.abs();
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
format!("{sign}{hours:02}:{minutes:02}")
}
fn is_safe_mysql_time_zone_name(value: &str) -> bool {
!value.is_empty()
&& value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'+' | b':'))
}
async fn verify_pool_connection(pool: &MySqlPool, timeout: Duration) -> Result<(), String> {
super::with_connection_timeout("MySQL", timeout, async {
let mut conn = pool.get_conn().await.map_err(|e| format!("MySQL connection failed: {e}"))?;
conn.ping().await.map_err(|e| format!("MySQL ping failed: {e}"))?;
Ok(())
})
.await
}
fn mysql_error_should_retry_without_ssl(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("handshakefailure")
|| error.contains("handshake")
|| error.contains("tls connection")
|| error.contains("server closed session")
// Some older MySQL proxies report a failed preferred-TLS probe as a
// protocol packet error instead of a TLS handshake error.
|| error.contains("packet out of order")
// Some MySQL-compatible servers report a preferred-TLS attempt as a
// normal server error instead of a TLS handshake error.
|| (error.contains("client asked for ssl") && error.contains("server does not have this capability"))
}
fn mysql_error_should_retry_with_legacy_eof(error: &str) -> bool {
error.to_ascii_lowercase().contains("packets out of sync")
}
fn mysql_error_should_retry_with_text_protocol(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
(lower.contains("1105") && lower.contains("hy000"))
|| (lower.contains("1615") && lower.contains("re-prepared"))
|| lower.contains("com_stmt_prepare")
|| lower.contains("can't parse")
|| lower.contains("buf doesn't have enough data")
|| lower.contains("prepared statement protocol")
|| lower.contains("this command is not supported in the prepared statement protocol yet")
}
fn ssl_fallback_url(url: &str) -> Option<String> {
if mysql_url_requires_ssl(url) {
return None;
}
let (base_url, fragment) = url.split_once('#').map_or((url, ""), |(base, fragment)| (base, fragment));
let Some(query_start) = base_url.find('?') else {
let mut fallback = format!("{base_url}?ssl-mode=disabled");
if !fragment.is_empty() {
fallback.push('#');
fallback.push_str(fragment);
}
return Some(fallback);
};
let prefix = &base_url[..query_start];
let query_string = &base_url[query_start + 1..];
let mut changed = false;
let mut kept_params = Vec::new();
for param in query_string.split('&') {
if param.is_empty() {
continue;
}
let Some((key, value)) = param.split_once('=') else {
kept_params.push(param.to_string());
continue;
};
if (key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
&& matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "preferred" | "prefer")
{
if !changed {
kept_params.push("ssl-mode=disabled".to_string());
}
changed = true;
} else {
kept_params.push(param.to_string());
}
}
if !changed
&& !kept_params.iter().any(|part| {
part.split_once('=')
.is_some_and(|(key, _)| key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
})
{
kept_params.push("ssl-mode=disabled".to_string());
changed = true;
}
if changed {
let mut fallback = prefix.to_string();
if !kept_params.is_empty() {
fallback.push('?');
fallback.push_str(&kept_params.join("&"));
}
if !fragment.is_empty() {
fallback.push('#');
fallback.push_str(fragment);
}
Some(fallback)
} else {
None
}
}
fn mysql_url_requires_ssl(url: &str) -> bool {
let Some((_, query)) = url.split_once('?') else {
return false;
};
query.split('&').any(|segment| {
let Some((key, value)) = segment.split_once('=') else {
return false;
};
let key = key.trim();
let value = value.trim();
(key.eq_ignore_ascii_case("require_ssl") && value.eq_ignore_ascii_case("true"))
|| mysql_tls_file_param_is(key, "cert")
|| mysql_tls_file_param_is(key, "key")
|| ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
&& matches!(
value.to_ascii_lowercase().replace('-', "_").as_str(),
"required" | "require" | "verify_ca" | "verify_identity"
))
})
}
fn mysql_url_attempts_ssl(url: &str) -> bool {
if mysql_url_requires_ssl(url) {
return true;
}
let Some((_, query)) = url.split_once('?') else {
return false;
};
query.split('&').any(|segment| {
let Some((key, value)) = segment.split_once('=') else {
return false;
};
let key = key.trim();
let value = value.trim();
(key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
&& matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "preferred" | "prefer")
})
}
fn mysql_url_verifies_identity(url: &str) -> bool {
let Some((_, query)) = url.split_once('?') else {
return false;
};
query.split('&').any(|segment| {
let Some((key, value)) = segment.split_once('=') else {
return false;
};
let key = key.trim();
let value = value.trim();
(key.eq_ignore_ascii_case("verify_identity") && value.eq_ignore_ascii_case("true"))
|| ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
&& matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "verify_identity"))
})
}
fn is_jdbc_param(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"useunicode"
| "characterencoding"
| "zerodatetimebehavior"
| "usessl"
| "servertimezone"
| "allowpublickeyretrieval"
| "autoreconnect"
| "maxreconnects"
| "uselegacydatetimecode"
| "usecompression"
| "cacheprepstmts"
| "useserverprepstmts"
| "useconfigs"
| "usecursorfetch"
| "defaultfetchsize"
| "usejdbccomplianttimezoneshift"
| "usesspscompatibletimezoneshift"
| "failoverreadonly"
| "maxallowedpacket"
| "tinyint1isbit"
| "transformedbitisboolean"
| "yearisdatetype"
| "createdatabaseifnotexist"
| "allowmultiqueries"
| "noaccesstoprocedurebodies"
| "nullcatalogmeanscurrent"
| "nullnamepatternmatchesall"
| "dumponqueriesexception"
| "enablequerytimeouts"
| "useinformationschema"
| "gatherperfmetrics"
| "reportmetricsintervalmillis"
| "maxquerysizetolog"
| "packetdebugbuffersize"
| "usenanosforelapsedtime"
| "slowquerythresholdmillis"
| "autoslowlog"
| "explainslowqueries"
| "resultsetsizethreshold"
| "nettimeoutforstreamingresults"
| "useusageadvisor"
| "uselocalsessionstate"
| "rewritebatchedstatements"
| "prepstmtcachesqllimit"
| "prepstmtcachesize"
)
}
fn is_dbx_handled_mysql_url_param(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"charset"
| "catalog"
| "time_zone"
| "time-zone"
| "timezone"
| "connect_timeout"
| "connecttimeout"
| "parsetime"
| "loc"
| "connectiontimezone"
| "servertimezone"
| "forceconnectiontimezonetosession"
| "sessionvariables"
| "localinfilepath"
)
}
fn is_mysql_cleartext_password_param(key: &str) -> bool {
matches!(key.to_ascii_lowercase().as_str(), "allowcleartextpasswords" | "enable_cleartext_plugin")
}
fn mysql_url_param_value_is_true(value: &str) -> bool {
matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on")
}
/// Strips the database path from a `mysql://[user[:pass]@]host[:port][/path]`
/// URL, returning only the scheme and authority. Used so mysql_async does not
/// send the database as the schema during the MySQL handshake (StarRocks would
/// reject an external-catalog database before `SET catalog` runs in setup).
fn strip_mysql_url_path(base: &str) -> &str {
let Some(rest) = base.strip_prefix("mysql://") else {
return base;
};
match rest.find('/') {
Some(idx) => &base[.."mysql://".len() + idx],
None => base,
}
}
fn mysql_async_url(url: &str) -> Cow<'_, str> {
let Some((base, query)) = url.split_once('?') else {
return Cow::Borrowed(url);
};
let original_count = query.split('&').filter(|segment| !segment.trim().is_empty()).count();
let mut filtered: Vec<String> = Vec::new();
let mut changed = false;
let mut has_catalog = false;
let mut enable_cleartext_plugin = false;
for segment in query.split('&') {
let segment = segment.trim();
if segment.is_empty() {
changed = true;
continue;
}
let Some((key, value)) = segment.split_once('=') else {
filtered.push(segment.to_string());
continue;
};
if key.eq_ignore_ascii_case("catalog") {
has_catalog = true;
}
if is_mysql_cleartext_password_param(key) {
changed = true;
enable_cleartext_plugin |= mysql_url_param_value_is_true(value);
continue;
}
if is_dbx_handled_mysql_url_param(key) {
changed = true;
continue;
}
if key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode") {
changed = true;
match value.to_ascii_lowercase().replace('-', "_").as_str() {
"disabled" | "disable" => filtered.push("require_ssl=false".to_string()),
"preferred" | "prefer" => {
filtered.push("require_ssl=true".to_string());
filtered.push("verify_ca=false".to_string());
filtered.push("verify_identity=false".to_string());
}
"required" | "require" => {
filtered.push("require_ssl=true".to_string());
filtered.push("verify_ca=false".to_string());
filtered.push("verify_identity=false".to_string());
}
"verify_ca" => {
filtered.push("require_ssl=true".to_string());
filtered.push("verify_identity=false".to_string());
}
"verify_identity" => filtered.push("require_ssl=true".to_string()),
_ => {}
}
continue;
}
if is_jdbc_param(key) {
changed = true;
continue;
}
filtered.push(segment.to_string());
}
if enable_cleartext_plugin {
filtered.push("enable_cleartext_plugin=true".to_string());
}
// When a catalog is configured, the database in the URL path must not be
// sent as the schema during the MySQL handshake. Strip the path so mysql_async
// connects without a default schema; the database is selected via setup queries.
let base = if has_catalog { strip_mysql_url_path(base) } else { base };
if !changed && filtered.len() == original_count && !has_catalog {
Cow::Borrowed(url)
} else if filtered.is_empty() {
Cow::Owned(base.to_string())
} else {
Cow::Owned(format!("{base}?{}", filtered.join("&")))
}
}
pub async fn connect_bare(url: &str, fallback_timeout: Duration) -> Result<MySqlPool, String> {
connect_bare_with_pool_limit(url, fallback_timeout, 3).await
}
pub async fn connect_bare_with_pool_limit(
url: &str,
fallback_timeout: Duration,
max_connections: usize,
) -> Result<MySqlPool, String> {
connect_bare_with_pool_limit_and_setup(url, fallback_timeout, max_connections, &[]).await
}
pub async fn connect_bare_with_pool_limit_and_setup(
url: &str,
fallback_timeout: Duration,
max_connections: usize,
extra_setup_queries: &[String],
) -> Result<MySqlPool, String> {
connect_bare_with_pool_limit_and_setup_database(url, fallback_timeout, max_connections, None, extra_setup_queries)
.await
}
pub async fn connect_bare_with_pool_limit_and_setup_database(
url: &str,
fallback_timeout: Duration,
max_connections: usize,
setup_database: Option<&str>,
extra_setup_queries: &[String],
) -> Result<MySqlPool, String> {
let timeout = super::parse_connect_timeout_with_fallback(url, fallback_timeout);
let result = connect_pool_attempt(
url,
None,
timeout,
max_connections,
None,
setup_database,
extra_setup_queries,
MySqlSetupMode::Compatible,
MySqlEofMode::Deprecate,
)
.await;
if result.as_ref().err().is_some_and(|error| mysql_error_should_retry_with_legacy_eof(error)) {
log::info!(
"MySQL proxy returned legacy EOF packets; retrying bare connection with CLIENT_DEPRECATE_EOF disabled"
);
return connect_pool_attempt(
url,
None,
timeout,
max_connections,
None,
setup_database,
extra_setup_queries,
MySqlSetupMode::Compatible,
MySqlEofMode::Legacy,
)
.await;
}
result
}
pub async fn list_databases(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = match conn.query_iter("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME").await
{
Ok(result) => result,
Err(err) => {
log::debug!("Falling back to SHOW DATABASES after information_schema.SCHEMATA failed: {err}");
return list_databases_show(pool).await;
}
};
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let databases = database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), false);
if databases.is_empty() {
log::debug!("Falling back to SHOW DATABASES after information_schema.SCHEMATA returned no named databases");
return list_databases_show(pool).await;
}
Ok(databases)
}
pub async fn list_databases_show(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter("SHOW DATABASES").await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
Ok(database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), true))
}
pub(super) fn database_infos_from_names(
names: impl IntoIterator<Item = String>,
include_catalogless_when_blank: bool,
) -> Vec<DatabaseInfo> {
let mut saw_row = false;
let mut databases: Vec<DatabaseInfo> = names
.into_iter()
.filter_map(|name| {
saw_row = true;
let name = name.trim().to_string();
(!name.is_empty()).then_some(DatabaseInfo { name })
})
.collect();
databases.sort_by(|a, b| a.name.cmp(&b.name));
if databases.is_empty() && saw_row && include_catalogless_when_blank {
return vec![DatabaseInfo { name: String::new() }];
}
databases
}
pub async fn list_tables(pool: &MySqlPool, database: &str) -> Result<Vec<TableInfo>, String> {
list_tables_filtered(pool, database, None, None, None, None, None).await
}
pub async fn list_tables_filtered(
pool: &MySqlPool,
database: &str,
filter: Option<&str>,
limit: Option<usize>,
offset: Option<usize>,
object_types: Option<&[String]>,
table_name_filter: Option<&TableNameFilter>,
) -> Result<Vec<TableInfo>, String> {
let sql = list_tables_sql(database, filter, limit, offset, object_types, table_name_filter);
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = match conn.query_iter(&sql).await {
Ok(result) => result,
Err(err) => {
log::debug!(
"Falling back to SHOW TABLES for database `{database}` after information_schema.TABLES failed: {err}"
);
return list_tables_show_filtered(pool, database, filter).await.map(|tables| {
filter_list_tables_fallback(tables, filter, limit, offset, object_types, table_name_filter)
});
}
};
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let tables: Vec<TableInfo> = rows
.iter()
.filter_map(|row| {
let name = get_str_by_name(row, "TABLE_NAME").trim().to_string();
(!name.is_empty()).then_some(TableInfo {
name,
table_type: get_str_by_name(row, "TABLE_TYPE"),
comment: get_opt_str(row, "TABLE_COMMENT")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
parent_schema: None,
parent_name: None,
})
})
.collect();
if tables.is_empty() {
log::debug!("Falling back to SHOW TABLES for database `{database}` after information_schema.TABLES returned no named tables");
return list_tables_show_filtered(pool, database, filter)
.await
.map(|tables| filter_list_tables_fallback(tables, filter, limit, offset, object_types, table_name_filter));
}
Ok(tables)
}
fn filter_list_tables_fallback(
tables: Vec<TableInfo>,
filter: Option<&str>,
limit: Option<usize>,
offset: Option<usize>,
object_types: Option<&[String]>,
table_name_filter: Option<&TableNameFilter>,
) -> Vec<TableInfo> {
let filter = filter.unwrap_or("").trim();
let normalized_object_types: Vec<String> = object_types
.unwrap_or(&[])
.iter()
.map(|object_type| object_type.to_ascii_uppercase().replace(' ', "_"))
.collect();
let wants_table =
normalized_object_types.is_empty() || normalized_object_types.iter().any(|object_type| object_type == "TABLE");
let wants_view =
normalized_object_types.is_empty() || normalized_object_types.iter().any(|object_type| object_type == "VIEW");
tables
.into_iter()
.filter(|table| {
crate::sql::contains_or_fuzzy_match(&table.name, filter)
|| table.comment.as_deref().is_some_and(|comment| crate::sql::contains_or_fuzzy_match(comment, filter))
})
.filter(|table| table_name_filter_matches(&table.name, table_name_filter))
.filter(|table| if table.table_type.eq_ignore_ascii_case("VIEW") { wants_view } else { wants_table })
.skip(offset.unwrap_or(0))
.take(limit.unwrap_or(usize::MAX))
.collect()
}
fn list_tables_sql(
database: &str,
filter: Option<&str>,
limit: Option<usize>,
offset: Option<usize>,
object_types: Option<&[String]>,
table_name_filter: Option<&TableNameFilter>,
) -> String {
let mut sql = format!(
"SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = {}",
quote_value(database),
);
if let Some(object_types) = object_types.filter(|object_types| !object_types.is_empty()) {
let wants_table = object_types
.iter()
.map(|object_type| object_type.to_ascii_uppercase().replace(' ', "_"))
.any(|object_type| object_type == "TABLE");
let wants_view = object_types
.iter()
.map(|object_type| object_type.to_ascii_uppercase().replace(' ', "_"))
.any(|object_type| object_type == "VIEW");
match (wants_table, wants_view) {
(true, false) => sql.push_str(" AND TABLE_TYPE <> 'VIEW'"),
(false, true) => sql.push_str(" AND TABLE_TYPE = 'VIEW'"),
(false, false) => sql.push_str(" AND 1 = 0"),
(true, true) => {}
}
}
if let Some(filter) = filter.map(str::trim).filter(|filter| !filter.is_empty()) {
let escaped = filter.to_ascii_lowercase().replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{}%", escaped);
if crate::sql::fuzzy_filter_enabled(filter) {
let fuzzy_pattern = crate::sql::fuzzy_like_pattern_with_escape(&filter.to_ascii_lowercase(), |value| {
value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
});
sql.push_str(&format!(
" AND (LOWER(TABLE_NAME) LIKE {} ESCAPE '\\\\' OR LOWER(TABLE_COMMENT) LIKE {} ESCAPE '\\\\' OR LOWER(TABLE_NAME) LIKE {} ESCAPE '\\\\' OR LOWER(TABLE_COMMENT) LIKE {} ESCAPE '\\\\')",
quote_value(&pattern),
quote_value(&pattern),
quote_value(&fuzzy_pattern),
quote_value(&fuzzy_pattern)
));
} else {
sql.push_str(&format!(
" AND (LOWER(TABLE_NAME) LIKE {} ESCAPE '\\\\' OR LOWER(TABLE_COMMENT) LIKE {} ESCAPE '\\\\')",
quote_value(&pattern),
quote_value(&pattern)
));
}
}
append_table_name_filter_sql(&mut sql, table_name_filter);
sql.push_str(" ORDER BY TABLE_NAME");
if let Some(limit) = limit {
sql.push_str(&format!(" LIMIT {}", limit));
}
if let Some(offset) = offset.filter(|offset| *offset > 0) {
sql.push_str(&format!(" OFFSET {}", offset));
}
sql
}
fn quote_table_name_like_pattern(pattern: &str) -> String {
quote_value(&pattern.trim().to_ascii_lowercase())
}
fn append_table_name_filter_sql(sql: &mut String, filter: Option<&TableNameFilter>) {
let Some(filter) = filter.filter(|filter| !filter.is_empty()) else {
return;
};
let include_patterns: Vec<&str> =
filter.include_patterns.iter().map(|pattern| pattern.trim()).filter(|pattern| !pattern.is_empty()).collect();
let exclude_patterns: Vec<&str> =
filter.exclude_patterns.iter().map(|pattern| pattern.trim()).filter(|pattern| !pattern.is_empty()).collect();
if !include_patterns.is_empty() {
let clauses = include_patterns
.iter()
.map(|pattern| format!("LOWER(TABLE_NAME) LIKE {} ESCAPE '\\\\'", quote_table_name_like_pattern(pattern)))
.collect::<Vec<_>>()
.join(" OR ");
sql.push_str(&format!(" AND ({clauses})"));
}
for pattern in exclude_patterns {
sql.push_str(&format!(
" AND LOWER(TABLE_NAME) NOT LIKE {} ESCAPE '\\\\'",
quote_table_name_like_pattern(pattern)
));
}
}
pub async fn completion_assistant_search(
pool: &MySqlPool,
request: &CompletionAssistantRequest,
) -> Result<CompletionAssistantResponse, String> {
let database = request.schema.as_deref().filter(|schema| !schema.trim().is_empty()).unwrap_or(&request.database);
let limit = request.max_results.unwrap_or(100).clamp(1, 1000);
let kinds = if request.object_kinds.is_empty() {
vec![CompletionAssistantObjectKind::Table, CompletionAssistantObjectKind::View]
} else {
request.object_kinds.clone()
};
let pattern = mysql_completion_like_pattern(&request.mask, request.match_mode.as_ref());
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let mut candidates = Vec::new();
if kinds
.iter()
.any(|kind| matches!(kind, CompletionAssistantObjectKind::Database | CompletionAssistantObjectKind::Schema))
{
let sql = mysql_completion_schemas_sql(&pattern, limit.saturating_sub(candidates.len()));
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
for row in rows {
let schema_name = get_str_by_name(&row, "schema_name");
candidates.push(CompletionAssistantCandidate {
name: schema_name.clone(),
kind: CompletionAssistantCandidateKind::Schema,
database: Some(schema_name.clone()),
schema: Some(schema_name),
parent_schema: None,
parent_name: None,
comment: None,
data_type: None,
signature: None,
});
}
}
if candidates.len() < limit && kinds.iter().any(CompletionAssistantObjectKind::is_table_like) {
let sql = mysql_completion_tables_sql(database, &pattern, &kinds, limit.saturating_sub(candidates.len()));
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
for row in rows {
let table_type = get_str_by_name(&row, "table_type");
candidates.push(CompletionAssistantCandidate {
name: get_str_by_name(&row, "object_name"),
kind: if table_type.eq_ignore_ascii_case("VIEW") {
CompletionAssistantCandidateKind::View
} else {
CompletionAssistantCandidateKind::Table
},
database: Some(database.to_string()),
schema: Some(database.to_string()),
parent_schema: None,
parent_name: None,
comment: get_opt_str(&row, "object_comment")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
data_type: None,
signature: None,
});
}
}
if candidates.len() < limit && kinds.iter().any(CompletionAssistantObjectKind::is_routine_like) {
let sql = mysql_completion_routines_sql(database, &pattern, &kinds, limit.saturating_sub(candidates.len()));
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
for row in rows {
let routine_type = get_str_by_name(&row, "routine_type");
candidates.push(CompletionAssistantCandidate {
name: get_str_by_name(&row, "object_name"),
kind: if routine_type.eq_ignore_ascii_case("PROCEDURE") {
CompletionAssistantCandidateKind::Procedure
} else {
CompletionAssistantCandidateKind::Function
},
database: Some(database.to_string()),
schema: Some(database.to_string()),
parent_schema: None,
parent_name: None,
comment: get_opt_str(&row, "object_comment")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
data_type: get_opt_str(&row, "data_type"),
signature: None,
});
}
}
if candidates.len() < limit && kinds.iter().any(|kind| matches!(kind, CompletionAssistantObjectKind::Column)) {
if let Some(table) = request.parent_name.as_deref().filter(|table| !table.trim().is_empty()) {
let sql = mysql_completion_columns_sql(database, table, &pattern, limit.saturating_sub(candidates.len()));
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
for row in rows {
candidates.push(CompletionAssistantCandidate {
name: get_str_by_name(&row, "object_name"),
kind: CompletionAssistantCandidateKind::Column,
database: Some(database.to_string()),
schema: Some(database.to_string()),
parent_schema: Some(database.to_string()),
parent_name: Some(table.to_string()),
comment: get_opt_str(&row, "object_comment")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
data_type: Some(get_str_by_name(&row, "data_type")),
signature: None,
});
}
}
}
Ok(CompletionAssistantResponse { incomplete: candidates.len() >= limit, candidates, fallback_used: false })
}
fn mysql_completion_schemas_sql(pattern: &str, limit: usize) -> String {
format!(
"SELECT SCHEMA_NAME AS schema_name \
FROM information_schema.SCHEMATA \
WHERE SCHEMA_NAME LIKE {} ESCAPE '\\\\' \
ORDER BY SCHEMA_NAME LIMIT {}",
quote_value(pattern),
limit,
)
}
fn mysql_completion_tables_sql(
database: &str,
pattern: &str,
kinds: &[CompletionAssistantObjectKind],
limit: usize,
) -> String {
let table_types = mysql_completion_table_types(kinds);
format!(
"SELECT TABLE_NAME AS object_name, TABLE_TYPE AS table_type, TABLE_COMMENT AS object_comment \
FROM information_schema.TABLES \
WHERE TABLE_SCHEMA = {db} AND TABLE_NAME LIKE {pattern} ESCAPE '\\\\' AND TABLE_TYPE IN ({table_types}) \
ORDER BY TABLE_NAME LIMIT {limit}",
db = quote_value(database),
pattern = quote_value(pattern),
table_types = table_types,
limit = limit,
)
}
fn mysql_completion_routines_sql(
database: &str,
pattern: &str,
kinds: &[CompletionAssistantObjectKind],
limit: usize,
) -> String {
let routine_types = mysql_completion_routine_types(kinds);
format!(
"SELECT ROUTINE_NAME AS object_name, ROUTINE_TYPE AS routine_type, ROUTINE_COMMENT AS object_comment, DATA_TYPE AS data_type \
FROM information_schema.ROUTINES \
WHERE ROUTINE_SCHEMA = {db} AND ROUTINE_NAME LIKE {pattern} ESCAPE '\\\\' AND ROUTINE_TYPE IN ({routine_types}) \
ORDER BY ROUTINE_NAME LIMIT {limit}",
db = quote_value(database),
pattern = quote_value(pattern),
routine_types = routine_types,
limit = limit,
)
}
fn mysql_completion_columns_sql(database: &str, table: &str, pattern: &str, limit: usize) -> String {
format!(
"SELECT COLUMN_NAME AS object_name, COLUMN_TYPE AS data_type, COLUMN_COMMENT AS object_comment \
FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = {db} AND TABLE_NAME = {table} AND COLUMN_NAME LIKE {pattern} ESCAPE '\\\\' \
ORDER BY ORDINAL_POSITION LIMIT {limit}",
db = quote_value(database),
table = quote_value(table),
pattern = quote_value(pattern),
limit = limit,
)
}
fn mysql_completion_table_types(kinds: &[CompletionAssistantObjectKind]) -> String {
let mut types = Vec::new();
if kinds.iter().any(|kind| matches!(kind, CompletionAssistantObjectKind::Table)) {
types.push("'BASE TABLE'");
types.push("'SYSTEM VERSIONED'");
}
if kinds.iter().any(|kind| matches!(kind, CompletionAssistantObjectKind::View)) {
types.push("'VIEW'");
}
if types.is_empty() {
"'BASE TABLE','VIEW'".to_string()
} else {
types.join(",")
}
}
fn mysql_completion_routine_types(kinds: &[CompletionAssistantObjectKind]) -> String {
let mut types = Vec::new();
if kinds
.iter()
.any(|kind| matches!(kind, CompletionAssistantObjectKind::Procedure | CompletionAssistantObjectKind::Routine))
{
types.push("'PROCEDURE'");
}
if kinds
.iter()
.any(|kind| matches!(kind, CompletionAssistantObjectKind::Function | CompletionAssistantObjectKind::Routine))
{
types.push("'FUNCTION'");
}
if types.is_empty() {
"'PROCEDURE','FUNCTION'".to_string()
} else {
types.join(",")
}
}
fn mysql_completion_like_pattern(value: &str, mode: Option<&CompletionAssistantMatchMode>) -> String {
if value.trim().is_empty() || value == "%" {
return "%".to_string();
}
let escaped = value.trim().replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
match mode.unwrap_or(&CompletionAssistantMatchMode::Prefix) {
CompletionAssistantMatchMode::Prefix => format!("{escaped}%"),
CompletionAssistantMatchMode::Contains => format!("%{escaped}%"),
}
}
fn table_comment_sql(database: &str, table: &str) -> String {
format!(
"SELECT TABLE_COMMENT \
FROM information_schema.TABLES \
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} AND TABLE_TYPE <> 'VIEW' \
LIMIT 1",
quote_value(database),
quote_value(table),
)
}
pub async fn get_table_comment(pool: &MySqlPool, database: &str, table: &str) -> Result<Option<String>, String> {
let sql = table_comment_sql(database, table);
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
Ok(rows
.first()
.and_then(|row| get_opt_str(row, "TABLE_COMMENT"))
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()))
}
#[derive(Clone, Debug, Default)]
struct TableStatusMeta {
comment: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
}
async fn list_table_status_show(pool: &MySqlPool, database: &str) -> Result<HashMap<String, TableStatusMeta>, String> {
query_table_status_show(pool, database, None).await
}
async fn list_table_status_show_filtered(
pool: &MySqlPool,
database: &str,
filter: Option<&str>,
) -> Result<HashMap<String, TableStatusMeta>, String> {
match query_table_status_show(pool, database, filter).await {
Ok(status) => Ok(status),
Err(filtered_err) => {
log::debug!(
"Falling back to unfiltered SHOW TABLE STATUS for database `{database}` after filtered SHOW failed: {filtered_err}"
);
query_table_status_show(pool, database, None)
.await
.map(|status| filter_table_status_fallback(status, filter))
}
}
}
async fn query_table_status_show(
pool: &MySqlPool,
database: &str,
filter: Option<&str>,
) -> Result<HashMap<String, TableStatusMeta>, String> {
let sql = show_table_status_sql(database, filter);
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
(
get_str_by_name(row, "Name"),
TableStatusMeta {
comment: get_opt_metadata_string(row, "Comment")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
created_at: get_opt_metadata_string(row, "Create_time"),
updated_at: get_opt_metadata_string(row, "Update_time"),
},
)
})
.filter(|(name, _)| !name.is_empty())
.collect())
}
fn filter_table_status_fallback(
status: HashMap<String, TableStatusMeta>,
filter: Option<&str>,
) -> HashMap<String, TableStatusMeta> {
let filter = filter.unwrap_or("").trim();
status
.into_iter()
.filter(|(name, meta)| {
crate::sql::contains_or_fuzzy_match(name, filter)
|| meta.comment.as_deref().is_some_and(|comment| crate::sql::contains_or_fuzzy_match(comment, filter))
})
.collect()
}
async fn list_table_names_show(pool: &MySqlPool, database: &str) -> Result<Vec<TableInfo>, String> {
list_table_names_show_filtered(pool, database, None, &[]).await
}
async fn list_table_names_show_filtered(
pool: &MySqlPool,
database: &str,
filter: Option<&str>,
exact_names: &[String],
) -> Result<Vec<TableInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let mut last_error = None;
let mut rows = None;
for attempt in show_tables_query_attempts(database, filter, exact_names) {
match conn.query_iter(&attempt.sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(result_rows) => {
rows = Some(result_rows);
break;
}
Err(err) => last_error = Some(err.to_string()),
},
Err(err) => {
if attempt.server_filtered {
log::debug!(
"Filtered SHOW TABLES is unsupported for database `{database}`; trying a compatible SHOW form: {err}"
);
}
last_error = Some(err.to_string());
}
}
}
let rows = rows.ok_or_else(|| last_error.unwrap_or_else(|| "SHOW TABLES returned no result".to_string()))?;
let mut tables: Vec<TableInfo> = rows
.iter()
.filter_map(|row| {
let name = get_str(row, 0).trim().to_string();
if name.is_empty() {
return None;
}
let table_type = get_str(row, 1);
Some(TableInfo {
name,
table_type: if table_type.is_empty() { "TABLE".to_string() } else { table_type },
comment: None,
parent_schema: None,
parent_name: None,
})
})
.collect();
tables.sort_by(|a, b| a.name.cmp(&b.name));
Ok(tables)
}
struct ShowTablesQueryAttempt {
sql: String,
server_filtered: bool,
}
fn show_tables_query_attempts(
database: &str,
filter: Option<&str>,
exact_names: &[String],
) -> Vec<ShowTablesQueryAttempt> {
// DBeaver-style server filtering is preferred for large schemas, but some
// MySQL proxies only implement bare SHOW TABLES forms.
let filtered_full = show_tables_filtered_sql(database, true, filter, exact_names);
let filtered_plain = show_tables_filtered_sql(database, false, filter, exact_names);
let unfiltered_full = show_tables_filtered_sql(database, true, None, &[]);
let unfiltered_plain = show_tables_filtered_sql(database, false, None, &[]);
let has_server_filter = filtered_full != unfiltered_full;
let mut attempts = Vec::with_capacity(if has_server_filter { 4 } else { 2 });
if has_server_filter {
attempts.push(ShowTablesQueryAttempt { sql: filtered_full, server_filtered: true });
attempts.push(ShowTablesQueryAttempt { sql: filtered_plain, server_filtered: true });
}
attempts.push(ShowTablesQueryAttempt { sql: unfiltered_full, server_filtered: false });
attempts.push(ShowTablesQueryAttempt { sql: unfiltered_plain, server_filtered: false });
attempts
}
fn show_tables_filtered_sql(database: &str, full: bool, filter: Option<&str>, exact_names: &[String]) -> String {
let prefix = if full { "SHOW FULL TABLES" } else { "SHOW TABLES" };
let mut sql = if database.trim().is_empty() {
prefix.to_string()
} else {
format!("{prefix} FROM {}", quote_identifier(database))
};
let conditions = show_tables_filter_conditions(database, filter, exact_names);
if !conditions.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conditions.join(" OR "));
}
sql
}
fn show_table_status_sql(database: &str, filter: Option<&str>) -> String {
let mut sql = if database.trim().is_empty() {
"SHOW TABLE STATUS".to_string()
} else {
format!("SHOW TABLE STATUS FROM {}", quote_identifier(database))
};
if let Some(filter) = filter.map(str::trim).filter(|filter| !filter.is_empty()) {
let patterns = mysql_fallback_like_patterns(filter);
let conditions = patterns
.iter()
.flat_map(|pattern| {
[
format!("Name LIKE {} ESCAPE '\\\\'", quote_value(pattern)),
format!("Comment LIKE {} ESCAPE '\\\\'", quote_value(pattern)),
]
})
.collect::<Vec<_>>();
sql.push_str(" WHERE ");
sql.push_str(&conditions.join(" OR "));
}
sql
}
fn show_tables_filter_conditions(database: &str, filter: Option<&str>, exact_names: &[String]) -> Vec<String> {
if database.trim().is_empty() {
// Catalogless services do not expose a stable Tables_in_<db> column name.
// Preserve the existing compatible SHOW syntax; local filtering still
// guarantees correctness for these uncommon endpoints.
return Vec::new();
}
let table_name_column = quote_identifier(&format!("Tables_in_{database}"));
let mut conditions = filter
.map(str::trim)
.filter(|filter| !filter.is_empty())
.into_iter()
.flat_map(mysql_fallback_like_patterns)
.map(|pattern| format!("{table_name_column} LIKE {} ESCAPE '\\\\'", quote_value(&pattern)))
.collect::<Vec<_>>();
conditions.extend(exact_names.iter().map(|name| format!("{table_name_column} = {}", quote_value(name))));
conditions
}
fn mysql_fallback_like_patterns(filter: &str) -> Vec<String> {
let escaped = filter.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let mut patterns = vec![format!("%{escaped}%")];
if crate::sql::fuzzy_filter_enabled(filter) {
patterns.push(crate::sql::fuzzy_like_pattern_with_escape(filter, |value| {
value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
}));
}
patterns
}
async fn list_tables_show_with_status(
pool: &MySqlPool,
database: &str,
) -> Result<(Vec<TableInfo>, HashMap<String, TableStatusMeta>), String> {
let (tables, status) = tokio::join!(list_table_names_show(pool, database), list_table_status_show(pool, database));
let mut tables = tables?;
let status = match status {
Ok(status) => status,
Err(err) => {
log::warn!("Skipping table status for database `{}`: {}", database, err);
HashMap::new()
}
};
for table in &mut tables {
if let Some(meta) = status.get(&table.name) {
table.comment = meta.comment.clone();
}
}
Ok((tables, status))
}
async fn list_tables_show_filtered(
pool: &MySqlPool,
database: &str,
filter: Option<&str>,
) -> Result<Vec<TableInfo>, String> {
if filter.is_none_or(|filter| filter.trim().is_empty()) {
return list_tables_show(pool, database).await;
}
let status = match list_table_status_show_filtered(pool, database, filter).await {
Ok(status) => status,
Err(err) => {
log::warn!("Skipping filtered table status for database `{}`: {}", database, err);
HashMap::new()
}
};
let exact_names = status.keys().cloned().collect::<Vec<_>>();
// DBeaver also uses SHOW FULL TABLES with server-side WHERE/LIKE filtering.
// Keeping the filter on the SHOW query avoids turning a normal empty search
// into an unbounded scan while preserving TABLE/VIEW classification.
let mut tables = list_table_names_show_filtered(pool, database, filter, &exact_names).await?;
for table in &mut tables {
if let Some(meta) = status.get(&table.name) {
table.comment = meta.comment.clone();
}
}
Ok(tables)
}
pub async fn list_tables_show(pool: &MySqlPool, database: &str) -> Result<Vec<TableInfo>, String> {
list_tables_show_with_status(pool, database).await.map(|(tables, _)| tables)
}
fn starrocks_materialized_views_sql(database: &str) -> String {
format!(
"SELECT TABLE_NAME FROM information_schema.materialized_views WHERE TABLE_SCHEMA = {}",
quote_value(database)
)
}
/// Fallback DDL source for StarRocks materialized views when `SHOW CREATE
/// MATERIALIZED VIEW` fails (e.g. on versions predating starrocks/starrocks#73396,
/// merged 2026-05-19, which reject the statement for sync MVs with "Table not
/// found" because sync MVs are not registered as separate Tables).
///
/// `information_schema.materialized_views` is documented as the authoritative
/// list of all materialized views, with a column distinguishing SYNC from
/// ASYNC. See
/// https://docs.starrocks.io/docs/sql-reference/information_schema/materialized_views/.
///
/// Made `pub(super)` so the dispatch site in `schema::mysql_object_source` can
/// rely on it without rewriting the escape convention.
pub(crate) fn mysql_materialized_view_definition_sql(database: &str, name: &str) -> String {
format!(
"SELECT MATERIALIZED_VIEW_DEFINITION \
FROM information_schema.materialized_views \
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
LIMIT 1",
quote_value(database),
quote_value(name)
)
}
async fn list_starrocks_materialized_view_names(pool: &MySqlPool, database: &str) -> Result<HashSet<String>, String> {
let sql = starrocks_materialized_views_sql(database);
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.filter_map(|row| {
let name = get_str_by_name(row, "TABLE_NAME").trim().to_string();
(!name.is_empty()).then_some(name)
})
.collect())
}
fn merge_starrocks_materialized_views(
tables: &mut Vec<TableInfo>,
materialized_view_names: Result<HashSet<String>, String>,
database: &str,
) {
let materialized_view_names = match materialized_view_names {
Ok(names) => names,
Err(err) => {
// Older StarRocks versions and restricted accounts may not expose this
// information_schema view; keep the base SHOW TABLES result usable.
log::warn!("Skipping materialized view classification for StarRocks database `{database}`: {err}");
return;
}
};
// Snapshot the names already returned by SHOW FULL TABLES so the second pass can
// append MVs that are absent from SHOW FULL TABLES without duplicating rows.
let known_names: HashSet<String> = tables.iter().map(|table| table.name.clone()).collect();
// Step 1 — reclassify: rows whose name appears in `information_schema.materialized_views`
// are MVs even when SHOW FULL TABLES labeled them as VIEW (sync MVs) or BASE TABLE
// (async MVs). See https://docs.starrocks.io/docs/sql-reference/information_schema/materialized_views/
// for the authoritative distinction between the two MV kinds.
for table in tables.iter_mut() {
if materialized_view_names.contains(&table.name) {
table.table_type = "MATERIALIZED_VIEW".to_string();
}
}
// Step 2 — union: on StarRocks versions predating starrocks/starrocks#73396 (merged
// 2026-05-19), sync MVs "are not registered as separate Tables" so SHOW FULL TABLES
// omits them entirely. Append those rows from the system view so they appear in the
// sidebar and the DDL source path has something to resolve. Sort names so that
// the resulting table order is deterministic across runs.
let mut materialized_view_names_sorted: Vec<&String> = materialized_view_names.iter().collect();
materialized_view_names_sorted.sort();
for name in materialized_view_names_sorted {
if !known_names.contains(name.as_str()) {
tables.push(TableInfo {
name: name.clone(),
table_type: "MATERIALIZED_VIEW".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
});
}
}
}
async fn list_starrocks_tables_with_status(
pool: &MySqlPool,
database: &str,
) -> Result<(Vec<TableInfo>, HashMap<String, TableStatusMeta>), String> {
let (tables, materialized_view_names) = tokio::join!(
list_tables_show_with_status(pool, database),
list_starrocks_materialized_view_names(pool, database)
);
let (mut tables, status) = tables?;
merge_starrocks_materialized_views(&mut tables, materialized_view_names, database);
Ok((tables, status))
}
pub async fn list_starrocks_tables(pool: &MySqlPool, database: &str) -> Result<Vec<TableInfo>, String> {
list_starrocks_tables_with_status(pool, database).await.map(|(tables, _)| tables)
}
fn requested_object_type(object_types: Option<&[String]>, object_type: &str) -> bool {
object_types.is_none_or(|types| {
types.is_empty() || types.iter().any(|candidate| candidate.eq_ignore_ascii_case(object_type))
})
}
fn wants_table_objects(object_types: Option<&[String]>) -> bool {
requested_object_type(object_types, "TABLE") || requested_object_type(object_types, "VIEW")
}
fn wants_routine_objects(object_types: Option<&[String]>) -> bool {
requested_object_type(object_types, "PROCEDURE") || requested_object_type(object_types, "FUNCTION")
}
fn sql_pagination(limit: Option<usize>, offset: Option<usize>) -> String {
limit.map_or_else(String::new, |limit| format!(" LIMIT {limit} OFFSET {}", offset.unwrap_or(0)))
}
fn list_tables_objects_sql(
database: &str,
object_types: Option<&[String]>,
limit: Option<usize>,
offset: Option<usize>,
) -> String {
let wants_tables = requested_object_type(object_types, "TABLE");
let wants_views = requested_object_type(object_types, "VIEW");
let type_filter = match (wants_tables, wants_views) {
(true, false) => " AND TABLE_TYPE <> 'VIEW'",
(false, true) => " AND TABLE_TYPE = 'VIEW'",
_ => "",
};
format!(
"SELECT TABLE_NAME AS object_name, \
CASE WHEN TABLE_TYPE = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS object_type, \
TABLE_COMMENT AS object_comment, \
CREATE_TIME AS created_at, \
UPDATE_TIME AS updated_at, \
NULL AS parent_schema, NULL AS parent_name, \
CASE WHEN TABLE_TYPE = 'VIEW' THEN 1 ELSE 0 END AS sort_order \
FROM information_schema.TABLES \
WHERE TABLE_SCHEMA = {db}{type_filter} \
ORDER BY sort_order, object_name{pagination}",
db = quote_value(database),
pagination = sql_pagination(limit, offset),
)
}
fn list_routines_sql(
database: &str,
object_types: Option<&[String]>,
limit: Option<usize>,
offset: Option<usize>,
) -> String {
let routine_types = [
("PROCEDURE", requested_object_type(object_types, "PROCEDURE")),
("FUNCTION", requested_object_type(object_types, "FUNCTION")),
]
.into_iter()
.filter_map(|(routine_type, requested)| requested.then_some(format!("'{}'", routine_type)))
.collect::<Vec<_>>()
.join(", ");
format!(
"SELECT ROUTINE_NAME AS object_name, ROUTINE_TYPE AS object_type, NULL AS object_comment, \
NULL AS created_at, NULL AS updated_at, \
NULL AS parent_schema, NULL AS parent_name, \
CASE WHEN ROUTINE_TYPE = 'PROCEDURE' THEN 2 ELSE 3 END AS sort_order \
FROM information_schema.ROUTINES \
WHERE ROUTINE_SCHEMA = {db} AND ROUTINE_TYPE IN ({routine_types}) \
ORDER BY sort_order, object_name{pagination}",
db = quote_value(database),
pagination = sql_pagination(limit, offset),
)
}
fn list_completion_triggers_sql(database: &str) -> String {
format!(
"SELECT TRIGGER_NAME AS object_name, 'TRIGGER' AS object_type, NULL AS object_comment, \
CREATED AS created_at, NULL AS updated_at, \
TRIGGER_SCHEMA AS parent_schema, EVENT_OBJECT_TABLE AS parent_name, \
4 AS sort_order \
FROM information_schema.TRIGGERS \
WHERE TRIGGER_SCHEMA = {db} \
ORDER BY object_name",
db = quote_value(database),
)
}
fn row_to_object(row: &mysql_async::Row, database: &str) -> ObjectInfo {
ObjectInfo {
name: get_str_by_name(row, "object_name"),
object_type: get_str_by_name(row, "object_type"),
schema: Some(database.to_string()),
valid: None,
signature: None,
comment: get_opt_str(row, "object_comment")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
created_at: get_opt_str(row, "created_at"),
updated_at: get_opt_str(row, "updated_at"),
parent_schema: get_opt_str(row, "parent_schema"),
parent_name: get_opt_str(row, "parent_name"),
}
}
pub struct PagedObjectList {
pub objects: Vec<ObjectInfo>,
pub paging_applied: bool,
}
fn object_query_supports_paging(object_types: Option<&[String]>) -> bool {
let Some(object_types) = object_types.filter(|types| !types.is_empty()) else {
return false;
};
let uses_table_source = object_types
.iter()
.any(|object_type| object_type.eq_ignore_ascii_case("TABLE") || object_type.eq_ignore_ascii_case("VIEW"));
let uses_routine_source = object_types.iter().any(|object_type| {
object_type.eq_ignore_ascii_case("PROCEDURE") || object_type.eq_ignore_ascii_case("FUNCTION")
});
let all_types_supported = object_types.iter().all(|object_type| {
object_type.eq_ignore_ascii_case("TABLE")
|| object_type.eq_ignore_ascii_case("VIEW")
|| object_type.eq_ignore_ascii_case("PROCEDURE")
|| object_type.eq_ignore_ascii_case("FUNCTION")
});
all_types_supported && uses_table_source != uses_routine_source
}
pub async fn list_objects(
pool: &MySqlPool,
database: &str,
object_types: Option<&[String]>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<PagedObjectList, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let wants_tables = wants_table_objects(object_types);
let wants_routines = wants_routine_objects(object_types);
let paging_applied = limit.is_some() && object_query_supports_paging(object_types);
let (query_limit, query_offset) = if paging_applied { (limit, offset) } else { (None, None) };
let mut objects = Vec::new();
if wants_tables {
let tables_sql = list_tables_objects_sql(database, object_types, query_limit, query_offset);
let table_rows = match conn.query_iter(&tables_sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(rows) if !rows.is_empty() => Some(rows),
Ok(_) => {
log::debug!(
"Falling back to SHOW TABLES for object browser database `{database}` after information_schema.TABLES returned no named tables"
);
None
}
Err(err) => {
log::debug!(
"Falling back to SHOW TABLES for object browser database `{database}` after information_schema.TABLES rows failed: {err}"
);
None
}
},
Err(err) => {
log::debug!(
"Falling back to SHOW TABLES for object browser database `{database}` after information_schema.TABLES failed: {err}"
);
None
}
};
if let Some(table_rows) = table_rows {
objects.extend(table_rows.iter().map(|row| row_to_object(row, database)));
} else {
drop(conn);
objects.extend(
list_table_objects_show_filtered(pool, database, object_types, query_limit, query_offset).await?,
);
if !wants_routines {
return Ok(PagedObjectList { objects, paging_applied });
}
conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
}
}
// Routines are queried separately: some MySQL-compatible servers (sharding proxies,
// OceanBase/TiDB variants, restricted accounts) reject information_schema.ROUTINES with
// ER_UNKNOWN_ERROR (1105). Degrading gracefully keeps tables/views usable.
if wants_routines {
let routines_sql = list_routines_sql(database, object_types, query_limit, query_offset);
match conn.query_iter(&routines_sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(routine_rows) => {
objects.extend(routine_rows.iter().map(|row| row_to_object(row, database)));
}
Err(e) => {
log::warn!("Skipping routines for database `{}` in object browser: {}", database, e);
}
},
Err(e) => {
log::warn!("Skipping routines for database `{}` in object browser: {}", database, e);
}
}
}
Ok(PagedObjectList { objects, paging_applied })
}
pub async fn list_object_statistics(pool: &MySqlPool, database: &str) -> Result<Vec<ObjectStatistics>, String> {
let sql = format!(
"SELECT TABLE_NAME, TABLE_ROWS, COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) AS TOTAL_BYTES \
FROM information_schema.TABLES \
WHERE TABLE_SCHEMA = {} AND TABLE_TYPE <> 'VIEW' \
ORDER BY TABLE_NAME",
quote_value(database),
);
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.filter_map(|row| {
let name = get_str_by_name(row, "TABLE_NAME").trim().to_string();
(!name.is_empty()).then_some(ObjectStatistics {
name,
schema: Some(database.to_string()),
estimated_rows: get_opt_i64(row, "TABLE_ROWS"),
total_bytes: get_opt_i64(row, "TOTAL_BYTES"),
})
})
.collect())
}
pub async fn list_table_objects_show(pool: &MySqlPool, database: &str) -> Result<Vec<ObjectInfo>, String> {
let (tables, routines) =
tokio::join!(list_tables_show_with_status(pool, database), list_routine_objects(pool, database));
let (tables, status) = tables?;
let mut objects = table_infos_to_objects(tables, &status, database);
match routines {
Ok(routines) => objects.extend(routines),
Err(err) => log::warn!("Skipping routines for database `{}` in object browser: {}", database, err),
}
Ok(objects)
}
async fn list_table_objects_show_filtered(
pool: &MySqlPool,
database: &str,
object_types: Option<&[String]>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Vec<ObjectInfo>, String> {
let (tables, status) = list_tables_show_with_status(pool, database).await?;
Ok(filter_table_objects_fallback(table_infos_to_objects(tables, &status, database), object_types, limit, offset))
}
fn filter_table_objects_fallback(
objects: Vec<ObjectInfo>,
object_types: Option<&[String]>,
limit: Option<usize>,
offset: Option<usize>,
) -> Vec<ObjectInfo> {
let wants_table = requested_object_type(object_types, "TABLE");
let wants_view = requested_object_type(object_types, "VIEW");
objects
.into_iter()
.filter(|object| if object.object_type.eq_ignore_ascii_case("VIEW") { wants_view } else { wants_table })
.skip(offset.unwrap_or(0))
.take(limit.unwrap_or(usize::MAX))
.collect()
}
pub async fn list_starrocks_table_objects(pool: &MySqlPool, database: &str) -> Result<Vec<ObjectInfo>, String> {
let (tables, routines) =
tokio::join!(list_starrocks_tables_with_status(pool, database), list_routine_objects(pool, database));
let (tables, status) = tables?;
let mut objects = table_infos_to_objects(tables, &status, database);
match routines {
Ok(routines) => objects.extend(routines),
Err(err) => log::warn!("Skipping routines for database `{}` in object browser: {}", database, err),
}
Ok(objects)
}
fn table_infos_to_objects(
tables: Vec<TableInfo>,
status: &HashMap<String, TableStatusMeta>,
database: &str,
) -> Vec<ObjectInfo> {
tables
.into_iter()
.map(|table| {
let meta = status.get(&table.name);
ObjectInfo {
name: table.name,
object_type: if table.table_type.eq_ignore_ascii_case("MATERIALIZED_VIEW") {
"MATERIALIZED_VIEW"
} else if table.table_type.eq_ignore_ascii_case("VIEW") {
"VIEW"
} else {
"TABLE"
}
.to_string(),
schema: Some(database.to_string()),
valid: None,
signature: None,
comment: table.comment,
created_at: meta.and_then(|meta| meta.created_at.clone()),
updated_at: meta.and_then(|meta| meta.updated_at.clone()),
parent_schema: table.parent_schema,
parent_name: table.parent_name,
}
})
.collect()
}
async fn list_routine_objects(pool: &MySqlPool, database: &str) -> Result<Vec<ObjectInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let routines_sql = list_routines_sql(database, None, None, None);
let result = conn.query_iter(&routines_sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| row_to_object(row, database)).collect())
}
pub async fn list_completion_objects(pool: &MySqlPool, database: &str) -> Result<Vec<ObjectInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let mut objects = Vec::new();
let routines_sql = list_routines_sql(database, None, None, None);
match conn.query_iter(&routines_sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(rows) => objects.extend(rows.iter().map(|row| row_to_object(row, database))),
Err(e) => log::warn!("Skipping routines for completion in database `{}`: {}", database, e),
},
Err(e) => log::warn!("Skipping routines for completion in database `{}`: {}", database, e),
}
let triggers_sql = list_completion_triggers_sql(database);
match conn.query_iter(&triggers_sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(rows) => objects.extend(rows.iter().map(|row| row_to_object(row, database))),
Err(e) => log::warn!("Skipping triggers for completion in database `{}`: {}", database, e),
},
Err(e) => log::warn!("Skipping triggers for completion in database `{}`: {}", database, e),
}
Ok(objects)
}
fn columns_sql(database: &str, table: &str) -> String {
format!(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, \
c.COLUMN_COMMENT, c.COLUMN_KEY, c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH, \
c.CHARACTER_SET_NAME, c.COLLATION_NAME, t.TABLE_COLLATION \
FROM information_schema.COLUMNS c \
LEFT JOIN information_schema.TABLES t \
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME \
WHERE c.TABLE_SCHEMA = {} AND c.TABLE_NAME = {} \
ORDER BY c.ORDINAL_POSITION",
quote_value(database),
quote_value(table),
)
}
fn table_collation_sql(database: &str, table: &str) -> String {
format!(
"SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} LIMIT 1",
quote_value(database),
quote_value(table),
)
}
fn normalize_mysql_column_charset_metadata(columns: &mut [ColumnInfo], table_collation: Option<&str>) {
let Some(table_collation) = table_collation.filter(|value| !value.trim().is_empty()) else {
return;
};
for column in columns {
if column.collation.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(table_collation)) {
// MySQL reports effective values and does not preserve whether an
// equivalent table-default collation was explicitly written.
column.character_set = None;
column.collation = None;
}
}
}
/// Attempt to reverse CP1252→UTF-8 double-encoding.
///
/// When Chinese text is written to MySQL through a connection with the wrong
/// charset (e.g. latin1/CP1252), each byte of the correct UTF-8 representation
/// is stored as a separate CP1252 character, then re-encoded as UTF-8 on read.
///
/// Example: "主键" → UTF-8 bytes [E4 B8 BB E9 94 AE]
/// → each byte → CP1252 char → UTF-8 re-encoded → garbled text
/// → reversal: map each char back to its CP1252 byte, decode as UTF-8
pub(super) fn fix_potential_double_encoding(s: &str) -> String {
// Map each character to its CP1252 byte value
let mut bytes = Vec::with_capacity(s.len());
for c in s.chars() {
let byte = match c as u32 {
// Characters in CP1252 that differ from Latin-1 (0x80-0x9F range)
0x20AC => 0x80, // €
0x201A => 0x82, // ‚
0x0192 => 0x83, // ƒ
0x201E => 0x84, // „
0x2026 => 0x85, // …
0x2020 => 0x86, // †
0x2021 => 0x87, // ‡
0x02C6 => 0x88, // ˆ
0x2030 => 0x89, // ‰
0x0160 => 0x8A, // Š
0x2039 => 0x8B, // ‹
0x0152 => 0x8C, // Œ
0x017D => 0x8E, // Ž
0x2018 => 0x91, // '
0x2019 => 0x92, // '
0x201C => 0x93, // " left double quotation mark
0x201D => 0x94, // " right double quotation mark
0x2022 => 0x95, // •
0x2013 => 0x96, // –
0x2014 => 0x97, // —
0x02DC => 0x98, // ˜
0x2122 => 0x99, // ™
0x0161 => 0x9A, // š
0x203A => 0x9B, // ›
0x0153 => 0x9C, // œ
0x017E => 0x9E, // ž
0x0178 => 0x9F, // Ÿ
v if v <= 0xFF => v as u8,
_ => return s.to_string(), // contains non-Latin1 char, skip
};
bytes.push(byte);
}
// Try decoding the bytes as UTF-8
match String::from_utf8(bytes) {
Ok(decoded) => {
// Only use the decoded version if it actually contains
// multi-byte UTF-8 characters (CJK, etc. > U+00FF),
// confirming the reversal was successful
if decoded.chars().any(|c| c > '\u{00FF}') {
decoded
} else {
s.to_string()
}
}
Err(_) => s.to_string(),
}
}
fn parse_mysql_enum_values(column_type: &str) -> Option<Vec<String>> {
let trimmed = column_type.trim();
if !trimmed.get(..5)?.eq_ignore_ascii_case("enum(") || !trimmed.ends_with(')') {
return None;
}
let inner = &trimmed[5..trimmed.len() - 1];
let mut chars = inner.chars().peekable();
let mut values = Vec::new();
loop {
while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
chars.next();
}
match chars.next() {
Some('\'') => {}
None if values.is_empty() => return Some(values),
_ => return None,
}
let mut value = String::new();
loop {
match chars.next() {
Some('\'') => {
if matches!(chars.peek(), Some('\'')) {
chars.next();
value.push('\'');
} else {
break;
}
}
Some('\\') => match chars.next() {
Some('0') => value.push('\0'),
Some('b') => value.push('\u{0008}'),
Some('n') => value.push('\n'),
Some('r') => value.push('\r'),
Some('t') => value.push('\t'),
Some('Z') => value.push('\u{001A}'),
Some(c @ ('\\' | '\'' | '"')) => value.push(c),
Some(c) => value.push(c),
None => return None,
},
Some(c) => value.push(c),
None => return None,
}
}
values.push(value);
while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
chars.next();
}
match chars.next() {
Some(',') => continue,
None => return Some(values),
_ => return None,
}
}
}
pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let sql = columns_sql(database, table);
let mut conn = get_conn_with_health_check(pool).await?;
let result = match conn.query_iter(&sql).await {
Ok(result) => result,
Err(err) => {
log::debug!(
"Falling back to SHOW COLUMNS for `{database}`.`{table}` after information_schema.COLUMNS failed: {err}"
);
return get_columns_show(pool, database, table).await;
}
};
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let table_collation =
rows.iter().find_map(|row| get_opt_str(row, "TABLE_COLLATION").filter(|value| !value.trim().is_empty()));
let mut columns: Vec<ColumnInfo> = rows
.iter()
.filter_map(|row| {
let name = get_str_by_name(row, "COLUMN_NAME").trim().to_string();
if name.is_empty() {
return None;
}
let column_key = get_str_by_name(row, "COLUMN_KEY");
let data_type = get_str_by_name(row, "DATA_TYPE");
let column_type = get_str_by_name(row, "COLUMN_TYPE");
let enum_values = if data_type.eq_ignore_ascii_case("enum") {
// MySQL exposes enum literals only through COLUMN_TYPE. Parse the SQL literal
// syntax in Rust so empty values, quotes, and backslash escapes survive intact.
parse_mysql_enum_values(&column_type)
} else {
None
};
Some(ColumnInfo {
is_primary_key: column_key.eq_ignore_ascii_case("PRI"),
name,
data_type: column_type,
is_nullable: get_str_by_name(row, "IS_NULLABLE") == "YES",
column_default: get_opt_str(row, "COLUMN_DEFAULT"),
extra: get_opt_str(row, "EXTRA"),
comment: get_opt_str(row, "COLUMN_COMMENT")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
numeric_precision: get_opt_i32(row, "NUMERIC_PRECISION"),
numeric_scale: get_opt_i32(row, "NUMERIC_SCALE"),
character_maximum_length: get_opt_i32(row, "CHARACTER_MAXIMUM_LENGTH"),
enum_values,
character_set: get_opt_str(row, "CHARACTER_SET_NAME").filter(|s| !s.is_empty()),
collation: get_opt_str(row, "COLLATION_NAME").filter(|s| !s.is_empty()),
})
})
.collect();
if columns.is_empty() {
log::debug!(
"Falling back to SHOW COLUMNS for `{database}`.`{table}` after information_schema.COLUMNS returned no named columns"
);
return get_columns_show(pool, database, table).await;
}
normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref());
Ok(columns)
}
pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let sql = show_columns_sql(database, table, true);
let mut conn = get_conn_with_health_check(pool).await?;
let rows: Vec<mysql_async::Row> = match conn.query_iter(&sql).await {
Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?,
Err(_) => {
let sql = show_columns_sql(database, table, false);
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
result.collect_and_drop().await.map_err(|e| e.to_string())?
}
};
let table_collation = if database.trim().is_empty() {
None
} else {
query_first_nonblank_string(&mut conn, &table_collation_sql(database, table)).await
};
let mut columns: Vec<ColumnInfo> = rows
.iter()
.filter_map(|row| {
let name = get_str_by_name(row, "Field").trim().to_string();
if name.is_empty() {
return None;
}
let key = get_str_by_name(row, "Key");
let collation = get_opt_str(row, "Collation").filter(|s| !s.is_empty());
Some(ColumnInfo {
name,
data_type: get_str_by_name(row, "Type"),
is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"),
column_default: get_opt_str(row, "Default"),
is_primary_key: key.eq_ignore_ascii_case("PRI"),
extra: get_opt_str(row, "Extra"),
comment: get_opt_str(row, "Comment")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
character_set: collation
.as_deref()
.and_then(|c| c.split_once('_').map(|(charset, _)| charset.to_string()))
.filter(|s| !s.is_empty()),
collation,
})
})
.collect();
normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref());
Ok(columns)
}
fn show_columns_sql(database: &str, table: &str, full: bool) -> String {
let prefix = if full { "SHOW FULL COLUMNS FROM" } else { "SHOW COLUMNS FROM" };
if database.trim().is_empty() {
format!("{prefix} {}", quote_identifier(table))
} else {
format!("{prefix} {}.{}", quote_identifier(database), quote_identifier(table))
}
}
fn query_result_row_limit(max_rows: Option<usize>) -> usize {
max_rows.unwrap_or(crate::query::MAX_ROWS).max(1)
}
fn should_collect_text_result_set(sql: &str, row_limit: usize, max_rows: Option<usize>) -> bool {
max_rows.is_some_and(|_| mysql_top_level_limit(sql).is_some_and(|limit| limit <= row_limit))
}
fn mysql_top_level_limit(sql: &str) -> Option<usize> {
let sql = sql.trim().trim_end_matches(';');
let bytes = sql.as_bytes();
let mut depth = 0usize;
let mut i = 0;
while i < bytes.len() {
i = skip_sql_whitespace_and_comments(bytes, i);
if i >= bytes.len() {
break;
}
let ch = bytes[i];
if matches!(ch, b'\'' | b'"' | b'`') {
i = skip_mysql_quoted(sql, i, ch);
continue;
}
if ch == b'(' {
depth += 1;
i += 1;
continue;
}
if ch == b')' {
depth = depth.saturating_sub(1);
i += 1;
continue;
}
if depth == 0 && mysql_keyword_at(sql, i, "LIMIT") {
return parse_mysql_limit_value(sql, i + "LIMIT".len());
}
// Move to next byte, but ensure we stay on a UTF-8 boundary
i += 1;
while i < bytes.len() && !sql.is_char_boundary(i) {
i += 1;
}
}
None
}
fn parse_mysql_limit_value(sql: &str, start: usize) -> Option<usize> {
let bytes = sql.as_bytes();
let mut i = skip_sql_whitespace_and_comments(bytes, start);
let first = parse_usize_token(sql, &mut i)?;
i = skip_sql_whitespace_and_comments(bytes, i);
if i < bytes.len() && bytes[i] == b',' {
i = skip_sql_whitespace_and_comments(bytes, i + 1);
return parse_usize_token(sql, &mut i);
}
Some(first)
}
fn parse_usize_token(sql: &str, i: &mut usize) -> Option<usize> {
let bytes = sql.as_bytes();
let start = *i;
while *i < bytes.len() && bytes[*i].is_ascii_digit() {
*i += 1;
}
if *i == start {
return None;
}
// Ensure the slice is valid UTF-8 before parsing
std::str::from_utf8(&bytes[start..*i]).ok()?.parse().ok()
}
pub(super) fn mysql_keyword_at(sql: &str, i: usize, keyword: &str) -> bool {
let end = i + keyword.len();
if end > sql.len() {
return false;
}
// Ensure indices are on UTF-8 boundaries before slicing
if !sql.is_char_boundary(i) || !sql.is_char_boundary(end) {
return false;
}
sql[i..end].eq_ignore_ascii_case(keyword)
&& (i == 0 || !is_mysql_identifier_byte(sql.as_bytes()[i - 1]))
&& (end == sql.len() || !is_mysql_identifier_byte(sql.as_bytes()[end]))
}
pub(super) fn is_mysql_identifier_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')
}
pub(super) fn skip_mysql_quoted(sql: &str, start: usize, quote: u8) -> usize {
let bytes = sql.as_bytes();
let mut i = start + 1;
while i < bytes.len() {
if bytes[i] == quote {
if i + 1 < bytes.len() && bytes[i + 1] == quote {
i += 2;
continue;
}
return i + 1;
}
if quote == b'\'' && bytes[i] == b'\\' {
i = (i + 2).min(bytes.len());
continue;
}
i += 1;
}
bytes.len()
}
/// Get a connection from the pool with a health check. If the connection is dead
/// (e.g. after app was backgrounded), it tries again with a fresh connection.
pub async fn get_conn_with_health_check(pool: &MySqlPool) -> Result<mysql_async::Conn, String> {
get_conn_with_health_check_with_timeout(pool, super::connection_timeout()).await
}
pub async fn get_conn_with_health_check_with_timeout(
pool: &MySqlPool,
timeout: Duration,
) -> Result<mysql_async::Conn, String> {
get_conn_with_health_check_with_cancel(pool, timeout, timeout, None).await
}
pub async fn get_conn_with_health_check_with_cancel(
pool: &MySqlPool,
timeout: Duration,
cleanup_timeout: Duration,
cancel_token: Option<&CancellationToken>,
) -> Result<mysql_async::Conn, String> {
let start = Instant::now();
let mut conn = get_conn_with_timeout_and_cancel(pool, timeout, cancel_token).await?;
match ping_conn_with_timeout_and_cancel(&mut conn, timeout, cancel_token).await {
Ok(()) => {
log::debug!(
"[db:health.check:done] elapsed_ms={} timeout_ms={}",
start.elapsed().as_millis(),
timeout.as_millis()
);
Ok(conn)
}
Err(err) if err == crate::query::QUERY_CANCELED => {
let _ = tokio::time::timeout(cleanup_timeout, conn.disconnect()).await;
Err(err)
}
Err(err) => {
log::warn!(
"[db:health.check:error] elapsed_ms={} timeout_ms={} error={}; retrying",
start.elapsed().as_millis(),
timeout.as_millis(),
err
);
let _ = tokio::time::timeout(cleanup_timeout, conn.disconnect()).await;
let mut conn = get_conn_with_timeout_and_cancel(pool, timeout, cancel_token).await?;
if let Err(err) = ping_conn_with_timeout_and_cancel(&mut conn, timeout, cancel_token).await {
if err == crate::query::QUERY_CANCELED {
let _ = tokio::time::timeout(cleanup_timeout, conn.disconnect()).await;
}
return Err(err);
}
log::info!(
"[db:health.check:recovered] elapsed_ms={} timeout_ms={}",
start.elapsed().as_millis(),
timeout.as_millis()
);
Ok(conn)
}
}
}
async fn get_conn_with_timeout_and_cancel(
pool: &MySqlPool,
timeout: Duration,
cancel_token: Option<&CancellationToken>,
) -> Result<mysql_async::Conn, String> {
let get_future = async {
tokio::time::timeout(timeout, pool.get_conn())
.await
.map_err(|_| "MySQL get connection timed out".to_string())?
.map_err(|e| e.to_string())
};
match cancel_token {
Some(token) => {
tokio::select! {
biased;
_ = token.cancelled() => Err(crate::query::canceled_error()),
result = get_future => result,
}
}
None => get_future.await,
}
}
pub async fn get_conn_with_timeout(pool: &MySqlPool, timeout: Duration) -> Result<mysql_async::Conn, String> {
tokio::time::timeout(timeout, pool.get_conn())
.await
.map_err(|_| "MySQL get connection timed out".to_string())?
.map_err(|e| e.to_string())
}
async fn ping_conn_with_timeout_and_cancel(
conn: &mut mysql_async::Conn,
timeout: Duration,
cancel_token: Option<&CancellationToken>,
) -> Result<(), String> {
let ping_future = async {
tokio::time::timeout(timeout, conn.ping())
.await
.map_err(|_| "MySQL ping timed out".to_string())?
.map_err(|e| e.to_string())
};
match cancel_token {
Some(token) => {
tokio::select! {
biased;
_ = token.cancelled() => Err(crate::query::canceled_error()),
result = ping_future => result,
}
}
None => ping_future.await,
}
}
async fn execute_result_set_with_text_protocol_on_conn(
conn: &mut mysql_async::Conn,
sql: &str,
row_limit: usize,
max_rows: Option<usize>,
start: Instant,
) -> Result<QueryResult, String> {
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
if !advance_to_result_set_with_columns(&mut result).await? {
return Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
column_sortables: vec![],
rows: vec![],
affected_rows: result.affected_rows(),
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
});
}
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
let column_types: Vec<String> = result.columns_ref().iter().map(mysql_column_type_name).collect();
if should_collect_text_result_set(sql, row_limit, max_rows) {
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let truncated = rows.len() > row_limit;
let result_rows = rows
.iter()
.take(row_limit)
.map(|row| (0..row.len()).map(|i| mysql_value_to_json(row, i)).collect())
.collect();
return Ok(QueryResult {
columns,
column_types,
column_sortables: vec![],
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
});
}
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut stream = result
.stream::<mysql_async::Row>()
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "Empty result set stream".to_string())?;
while let Some(row) = stream.next().await {
let row = row.map_err(|e| e.to_string())?;
let values: Vec<serde_json::Value> = (0..row.len()).map(|i| mysql_value_to_json(&row, i)).collect();
result_rows.push(values);
if result_rows.len() > row_limit {
break;
}
}
let truncated = result_rows.len() > row_limit;
if truncated {
result_rows.truncate(row_limit);
}
Ok(QueryResult {
columns,
column_types,
column_sortables: vec![],
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
})
}
async fn advance_to_result_set_with_columns(
result: &mut mysql_async::QueryResult<'_, '_, mysql_async::TextProtocol>,
) -> Result<bool, String> {
while result.columns_ref().is_empty() {
if result.is_empty() {
return Ok(false);
}
let _: Vec<mysql_async::Row> = result.collect().await.map_err(|e| e.to_string())?;
}
Ok(!result.columns_ref().is_empty())
}
async fn execute_result_set_with_prepared_protocol_on_conn(
conn: &mut mysql_async::Conn,
sql: &str,
row_limit: usize,
start: Instant,
) -> Result<QueryResult, String> {
let mut result = conn.exec_iter(sql, ()).await.map_err(|e| e.to_string())?;
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
let column_types: Vec<String> = result.columns_ref().iter().map(mysql_column_type_name).collect();
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut stream = result
.stream::<mysql_async::Row>()
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "Empty result set stream".to_string())?;
while let Some(row) = stream.next().await {
let row = row.map_err(|e| e.to_string())?;
let values: Vec<serde_json::Value> = (0..row.len()).map(|i| mysql_value_to_json(&row, i)).collect();
result_rows.push(values);
if result_rows.len() > row_limit {
break;
}
}
let truncated = result_rows.len() > row_limit;
if truncated {
result_rows.truncate(row_limit);
}
Ok(QueryResult {
columns,
column_types,
column_sortables: vec![],
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
})
}
pub async fn execute_query(pool: &MySqlPool, sql: &str, bare: bool) -> Result<QueryResult, String> {
execute_query_with_max_rows(pool, sql, bare, None, MySqlQueryDialect::default()).await
}
pub async fn execute_query_with_max_rows(
pool: &MySqlPool,
sql: &str,
bare: bool,
max_rows: Option<usize>,
dialect: MySqlQueryDialect,
) -> Result<QueryResult, String> {
let mut conn = get_conn_with_health_check(pool).await?;
execute_query_on_conn_with_max_rows(&mut conn, sql, bare, max_rows, dialect).await
}
pub async fn stream_query_rows(
pool: &MySqlPool,
sql: &str,
bare: bool,
max_rows: Option<usize>,
dialect: MySqlQueryDialect,
cancelled: &AtomicBool,
mut on_row: impl FnMut(&[serde_json::Value]) -> Result<(), String>,
) -> Result<u64, String> {
let mut conn = get_conn_with_health_check(pool).await?;
stream_query_result_on_conn(&mut conn, sql, bare, max_rows, dialect, cancelled, |item| {
if let MySqlQueryStreamItem::Row(row) = item {
on_row(&row)?;
}
Ok(())
})
.await
}
pub async fn stream_query_result_on_conn(
conn: &mut mysql_async::Conn,
sql: &str,
bare: bool,
max_rows: Option<usize>,
dialect: MySqlQueryDialect,
cancelled: &AtomicBool,
mut on_item: impl FnMut(MySqlQueryStreamItem) -> Result<(), String>,
) -> Result<u64, String> {
let row_limit = max_rows.unwrap_or(usize::MAX);
if bare || prefers_text_protocol_query(sql, dialect) {
stream_query_result_text(conn, sql, row_limit, cancelled, &mut on_item).await
} else {
match stream_query_result_prepared(conn, sql, row_limit, cancelled, &mut on_item).await {
Ok(rows) => Ok(rows),
Err(err) if mysql_error_should_retry_with_text_protocol(&err) => {
stream_query_result_text(conn, sql, row_limit, cancelled, &mut on_item).await
}
Err(err) => Err(err),
}
}
}
async fn stream_query_result_text(
conn: &mut mysql_async::Conn,
sql: &str,
row_limit: usize,
cancelled: &AtomicBool,
on_item: &mut impl FnMut(MySqlQueryStreamItem) -> Result<(), String>,
) -> Result<u64, String> {
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
if !advance_to_result_set_with_columns(&mut result).await? {
return Ok(0);
}
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
let column_types: Vec<String> = result.columns_ref().iter().map(mysql_column_type_name).collect();
on_item(MySqlQueryStreamItem::Columns { columns, column_types })?;
let mut stream = result
.stream::<mysql_async::Row>()
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "Empty result set stream".to_string())?;
let mut rows_exported = 0_u64;
while let Some(row) = stream.next().await {
if cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Err(crate::query::canceled_error());
}
if rows_exported as usize >= row_limit {
break;
}
let row = row.map_err(|e| e.to_string())?;
let values: Vec<serde_json::Value> = (0..row.len()).map(|i| mysql_value_to_json(&row, i)).collect();
on_item(MySqlQueryStreamItem::Row(values))?;
rows_exported += 1;
}
Ok(rows_exported)
}
async fn stream_query_result_prepared(
conn: &mut mysql_async::Conn,
sql: &str,
row_limit: usize,
cancelled: &AtomicBool,
on_item: &mut impl FnMut(MySqlQueryStreamItem) -> Result<(), String>,
) -> Result<u64, String> {
let mut result = conn.exec_iter(sql, ()).await.map_err(|e| e.to_string())?;
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
if columns.is_empty() {
return Ok(0);
}
let column_types: Vec<String> = result.columns_ref().iter().map(mysql_column_type_name).collect();
on_item(MySqlQueryStreamItem::Columns { columns, column_types })?;
let mut stream = result
.stream::<mysql_async::Row>()
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "Empty result set stream".to_string())?;
let mut rows_exported = 0_u64;
while let Some(row) = stream.next().await {
if cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Err(crate::query::canceled_error());
}
if rows_exported as usize >= row_limit {
break;
}
let row = row.map_err(|e| e.to_string())?;
let values: Vec<serde_json::Value> = (0..row.len()).map(|i| mysql_value_to_json(&row, i)).collect();
on_item(MySqlQueryStreamItem::Row(values))?;
rows_exported += 1;
}
Ok(rows_exported)
}
pub async fn kill_query(pool: &MySqlPool, connection_id: u32) -> Result<(), String> {
let start = Instant::now();
let timeout = super::connection_timeout();
let mut conn = tokio::time::timeout(timeout, pool.get_conn())
.await
.map_err(|_| {
log::warn!(
"[db:cancel:error] elapsed_ms={} timeout_ms={} error=MySQL kill connection checkout timed out",
start.elapsed().as_millis(),
timeout.as_millis()
);
"MySQL kill connection checkout timed out".to_string()
})?
.map_err(|e| e.to_string())?;
tokio::time::timeout(timeout, conn.query_drop(format!("KILL QUERY {connection_id}")))
.await
.map_err(|_| {
log::warn!(
"[db:cancel:error] elapsed_ms={} timeout_ms={} error=MySQL KILL QUERY timed out",
start.elapsed().as_millis(),
timeout.as_millis()
);
"MySQL KILL QUERY timed out".to_string()
})?
.map_err(|e| e.to_string())?;
log::info!("[db:cancel:done] elapsed_ms={} timeout_ms={}", start.elapsed().as_millis(), timeout.as_millis());
Ok(())
}
pub async fn kill_query_with_opts(opts: mysql_async::Opts, connection_id: u32) -> Result<(), String> {
let start = Instant::now();
let timeout = super::connection_timeout();
let mut conn = tokio::time::timeout(timeout, mysql_async::Conn::new(opts))
.await
.map_err(|_| {
log::warn!(
"[db:cancel:error] elapsed_ms={} timeout_ms={} error=MySQL kill connection timed out",
start.elapsed().as_millis(),
timeout.as_millis()
);
"MySQL kill connection timed out".to_string()
})?
.map_err(|e| e.to_string())?;
tokio::time::timeout(timeout, conn.query_drop(format!("KILL QUERY {connection_id}")))
.await
.map_err(|_| {
log::warn!(
"[db:cancel:error] elapsed_ms={} timeout_ms={} error=MySQL KILL QUERY execution timed out",
start.elapsed().as_millis(),
timeout.as_millis()
);
"MySQL KILL QUERY execution timed out".to_string()
})?
.map_err(|e| e.to_string())?;
log::info!("[db:cancel:done] elapsed_ms={} timeout_ms={}", start.elapsed().as_millis(), timeout.as_millis());
Ok(())
}
pub async fn execute_query_on_conn_with_max_rows(
conn: &mut mysql_async::Conn,
sql: &str,
bare: bool,
max_rows: Option<usize>,
dialect: MySqlQueryDialect,
) -> Result<QueryResult, String> {
let start = Instant::now();
let row_limit = query_result_row_limit(max_rows);
if is_result_set_query(sql, dialect) {
if bare || prefers_text_protocol_query(sql, dialect) {
execute_result_set_with_text_protocol_on_conn(conn, sql, row_limit, max_rows, start).await
} else {
match execute_result_set_with_prepared_protocol_on_conn(conn, sql, row_limit, start).await {
Ok(result) => Ok(result),
Err(err) if mysql_error_should_retry_with_text_protocol(&err) => {
execute_result_set_with_text_protocol_on_conn(conn, sql, row_limit, max_rows, start).await
}
Err(err) => Err(err),
}
}
} else {
let previous_explicit_timestamp_defaults = enable_explicit_timestamp_defaults_for_query(conn, sql).await;
let result = match conn.query_iter(sql).await {
Ok(result) => result,
Err(err) => {
restore_explicit_timestamp_defaults_for_query(conn, previous_explicit_timestamp_defaults).await;
return Err(err.to_string());
}
};
let affected_rows = result.affected_rows();
let drop_result = result.drop_result().await;
restore_explicit_timestamp_defaults_for_query(conn, previous_explicit_timestamp_defaults).await;
drop_result.map_err(|e| e.to_string())?;
Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
column_sortables: vec![],
rows: vec![],
affected_rows,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
})
}
}
fn prefers_text_protocol_query(sql: &str, dialect: MySqlQueryDialect) -> bool {
// User-entered result-set queries are not parameterized in DBX. Text protocol
// avoids binary result decoding bugs in MySQL-compatible servers and proxies.
is_result_set_query(sql, dialect) || requires_text_protocol_query(sql, dialect)
}
fn is_result_set_query(sql: &str, dialect: MySqlQueryDialect) -> bool {
starts_with_executable_sql_keyword_for_database(
sql,
&["SELECT", "SHOW", "DESCRIBE", "EXPLAIN", "WITH", "CALL"],
DatabaseType::Mysql,
) || dialect.supports_admin_show_results && is_admin_show_query(sql)
}
fn requires_text_protocol_query(sql: &str, dialect: MySqlQueryDialect) -> bool {
if dialect.supports_admin_show_results && is_admin_show_query(sql) {
return true;
}
if !starts_with_executable_sql_keyword_for_database(sql, &["SHOW"], DatabaseType::Mysql) {
return false;
}
let tokens = leading_sql_word_tokens(sql, 3);
if tokens.len() >= 2 && tokens[0] == "show" && tokens[1] == "grants" {
return true;
}
matches!(
tokens.iter().map(String::as_str).collect::<Vec<_>>().as_slice(),
["show", "processlist"]
| ["show", "full", "processlist"]
| ["show", "slave", "status"]
| ["show", "replica", "status"]
)
}
fn is_admin_show_query(sql: &str) -> bool {
let tokens = leading_sql_word_tokens(sql, 2);
tokens.first().is_some_and(|token| token == "admin") && tokens.get(1).is_some_and(|token| token == "show")
}
fn leading_sql_word_tokens(sql: &str, limit: usize) -> Vec<String> {
let bytes = sql.as_bytes();
let mut i = 0;
let mut tokens = Vec::new();
while i < bytes.len() && tokens.len() < limit {
i = skip_sql_whitespace_and_comments(bytes, i);
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
i += 1;
}
if i == start {
break;
}
tokens.push(sql[start..i].to_ascii_lowercase());
}
tokens
}
fn skip_sql_whitespace_and_comments(bytes: &[u8], mut i: usize) -> usize {
loop {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b'-' {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if i < bytes.len() && bytes[i] == b'#' {
i += 1;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
continue;
}
return i;
}
}
fn mysql_list_indexes_sql(database: &str, table: &str, include_expression: bool) -> String {
let expression_column = if include_expression { "EXPRESSION, " } else { "" };
format!(
"SELECT INDEX_NAME, COLUMN_NAME, {expression_column}SEQ_IN_INDEX, NON_UNIQUE, INDEX_TYPE, INDEX_COMMENT \
FROM information_schema.STATISTICS \
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
ORDER BY INDEX_NAME, SEQ_IN_INDEX",
quote_value(database),
quote_value(table),
)
}
fn mysql_statistics_expression_is_unsupported(error: &mysql_async::Error) -> bool {
matches!(error, mysql_async::Error::Server(server_error) if server_error.code == 1054)
}
pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let expression_sql = mysql_list_indexes_sql(database, table, true);
let legacy_sql = mysql_list_indexes_sql(database, table, false);
let (result, include_expression) = match conn.query_iter(&expression_sql).await {
Ok(result) => (result, true),
Err(error) if mysql_statistics_expression_is_unsupported(&error) => {
// MySQL 5.7 and older compatible servers do not expose EXPRESSION; keep the legacy metadata path.
log::debug!("MySQL index expressions are unavailable, retrying without EXPRESSION: {error}");
(conn.query_iter(&legacy_sql).await.map_err(|e| e.to_string())?, false)
}
Err(error) => return Err(error.to_string()),
};
let mut indexes = Vec::new();
let mut index_positions = HashMap::new();
result
.for_each_and_drop(|row| {
let name = get_str_by_name(&row, "INDEX_NAME");
let index_position = if let Some(index_position) = index_positions.get(&name) {
*index_position
} else {
let index_position = indexes.len();
index_positions.insert(name.clone(), index_position);
indexes.push(IndexInfo {
name: name.clone(),
columns: Vec::new(),
is_unique: get_opt_i32(&row, "NON_UNIQUE").unwrap_or(1) == 0,
is_primary: name == "PRIMARY",
filter: None,
index_type: Some(get_str_by_name(&row, "INDEX_TYPE")),
included_columns: None,
comment: get_opt_str(&row, "INDEX_COMMENT").filter(|value| !value.is_empty()),
});
index_position
};
let index_part = if include_expression {
get_opt_str(&row, "EXPRESSION")
.filter(|value| !value.trim().is_empty())
.map(|expression| format!("({})", expression.trim()))
.or_else(|| get_opt_str(&row, "COLUMN_NAME").filter(|value| !value.is_empty()))
} else {
get_opt_str(&row, "COLUMN_NAME").filter(|value| !value.is_empty())
};
if let Some(index_part) = index_part {
indexes[index_position].columns.push(index_part);
}
})
.await
.map_err(|e| e.to_string())?;
Ok(indexes)
}
pub async fn show_create_table_ddl(pool: &MySqlPool, database: &str, table: &str) -> Result<String, String> {
let sql = format!("SHOW CREATE TABLE {}", quote_table_ref(database, table));
let mut conn = get_conn_with_health_check(pool).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let row = rows.first().ok_or("DDL not found")?;
row.get_opt::<String, usize>(1)
.and_then(|result| result.ok())
.or_else(|| row.get_opt::<Vec<u8>, usize>(1).and_then(|result| result.ok()).map(bytes_to_string_lossy))
.ok_or_else(|| "Failed to read DDL".to_string())
}
pub async fn list_foreign_keys(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let column_sql = format!(
"SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, \
REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME \
FROM information_schema.KEY_COLUMN_USAGE \
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
AND REFERENCED_TABLE_NAME IS NOT NULL \
ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION",
quote_value(database),
quote_value(table),
);
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let column_result = conn.query_iter(&column_sql).await.map_err(|e| e.to_string())?;
let column_rows: Vec<mysql_async::Row> = column_result.collect_and_drop().await.map_err(|e| e.to_string())?;
if column_rows.is_empty() {
return Ok(Vec::new());
}
// MySQL 5.7 materializes information_schema tables without normal indexes.
// Avoid joining two metadata tables because the join can scan the entire catalog.
let rule_sql = format!(
"SELECT CONSTRAINT_NAME, UPDATE_RULE, DELETE_RULE \
FROM information_schema.REFERENTIAL_CONSTRAINTS \
WHERE CONSTRAINT_SCHEMA = {} AND TABLE_NAME = {}",
quote_value(database),
quote_value(table),
);
let rule_result = conn.query_iter(&rule_sql).await.map_err(|e| e.to_string())?;
let rule_rows: Vec<mysql_async::Row> = rule_result.collect_and_drop().await.map_err(|e| e.to_string())?;
let rules = rule_rows
.iter()
.map(|row| {
(
get_str_by_name(row, "CONSTRAINT_NAME"),
(get_str_by_name(row, "UPDATE_RULE"), get_str_by_name(row, "DELETE_RULE")),
)
})
.collect::<HashMap<_, _>>();
Ok(column_rows
.iter()
.map(|row| {
let name = get_str_by_name(row, "CONSTRAINT_NAME");
let (on_update, on_delete) = rules.get(&name).cloned().unwrap_or_default();
ForeignKeyInfo {
name,
column: get_str_by_name(row, "COLUMN_NAME"),
ref_schema: Some(get_str_by_name(row, "REFERENCED_TABLE_SCHEMA")),
ref_table: get_str_by_name(row, "REFERENCED_TABLE_NAME"),
ref_column: get_str_by_name(row, "REFERENCED_COLUMN_NAME"),
on_update: Some(on_update).filter(|value| !value.is_empty()),
on_delete: Some(on_delete).filter(|value| !value.is_empty()),
}
})
.collect())
}
pub async fn list_triggers(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
let sql = format!(
"SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING, ACTION_STATEMENT \
FROM information_schema.TRIGGERS \
WHERE TRIGGER_SCHEMA = {} AND EVENT_OBJECT_TABLE = {} \
ORDER BY TRIGGER_NAME",
quote_value(database),
quote_value(table),
);
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| TriggerInfo {
name: get_str_by_name(row, "TRIGGER_NAME"),
event: get_str_by_name(row, "EVENT_MANIPULATION"),
timing: get_str_by_name(row, "ACTION_TIMING"),
statement: Some(get_str_by_name(row, "ACTION_STATEMENT")).filter(|value| !value.is_empty()),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::connection_timeout;
use mysql_async::consts::ColumnFlags;
fn mysql_test_object(name: &str, object_type: &str) -> ObjectInfo {
ObjectInfo {
name: name.to_string(),
object_type: object_type.to_string(),
schema: Some("app".to_string()),
valid: None,
signature: None,
comment: None,
created_at: None,
updated_at: None,
parent_schema: None,
parent_name: None,
}
}
#[test]
fn bytes_to_string_reuses_valid_utf8_and_falls_back_lossy() {
assert_eq!(super::bytes_to_string_lossy("héllo 世界".as_bytes().to_vec()), "héllo 世界");
assert_eq!(super::bytes_to_string_lossy(vec![]), "");
// 非法 UTF-8 序列退化为替换字符,与 from_utf8_lossy 语义一致
let invalid = vec![0x66, 0x6f, 0xff, 0x6f];
assert_eq!(super::bytes_to_string_lossy(invalid.clone()), String::from_utf8_lossy(&invalid));
}
#[test]
fn mysql_column_type_names_map_to_friendly_names() {
use mysql_async::consts::ColumnType::*;
let utf8 = 45u16;
let binary = 63u16;
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_TINY, utf8, ColumnFlags::empty(), 4)),
"tinyint"
);
assert_eq!(mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_LONG, utf8, ColumnFlags::empty(), 11)), "int");
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_LONGLONG, utf8, ColumnFlags::empty(), 20)),
"bigint"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_NEWDECIMAL, utf8, ColumnFlags::empty(), 10)),
"decimal"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_VARCHAR, utf8, ColumnFlags::empty(), 255)),
"varchar"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_VAR_STRING, utf8, ColumnFlags::empty(), 255)),
"varchar"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_STRING, utf8, ColumnFlags::empty(), 16)),
"char"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_STRING, utf8, ColumnFlags::ENUM_FLAG, 16)),
"enum"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_STRING, utf8, ColumnFlags::SET_FLAG, 16)),
"set"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_DATETIME, utf8, ColumnFlags::empty(), 19)),
"datetime"
);
assert_eq!(mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_JSON, utf8, ColumnFlags::empty(), 0)), "json");
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_BLOB, binary, ColumnFlags::BLOB_FLAG, 65_535)),
"blob"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_BLOB, utf8, ColumnFlags::empty(), 65_535)),
"text"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_TINY_BLOB, utf8, ColumnFlags::empty(), 255)),
"tinytext"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_MEDIUM_BLOB, utf8, ColumnFlags::empty(), 16_777_215)),
"mediumtext"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_LONG_BLOB, utf8, ColumnFlags::empty(), 4_294_967_295)),
"longtext"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_VAR_STRING, binary, ColumnFlags::BINARY_FLAG, 16)),
"varbinary"
);
assert_eq!(
mysql_column_type_name(&mysql_test_column(MYSQL_TYPE_STRING, binary, ColumnFlags::BINARY_FLAG, 16)),
"binary"
);
}
#[test]
fn mysql_with_queries_are_treated_as_result_sets() {
let sql = "WITH RECURSIVE org_tree AS (SELECT 1 AS id) SELECT id FROM org_tree";
assert!(is_result_set_query(sql, MySqlQueryDialect::default()));
}
#[test]
fn mysql_hash_comments_before_queries_preserve_result_sets_per_issue_3830() {
let dialect = MySqlQueryDialect::default();
assert!(is_result_set_query("# 注释\nSELECT NOW()", dialect));
assert!(prefers_text_protocol_query("# 注释\nSELECT NOW()", dialect));
assert!(requires_text_protocol_query("# inspect sessions\nSHOW PROCESSLIST", dialect));
assert!(!is_result_set_query("# update row\nUPDATE users SET name = 'Ada' WHERE id = 1", dialect));
}
#[test]
fn mysql_desc_queries_are_treated_as_result_sets() {
assert!(is_result_set_query("DESC users", MySqlQueryDialect::default()));
}
#[test]
fn mysql_call_queries_are_treated_as_text_result_sets() {
let dialect = MySqlQueryDialect::default();
assert!(is_result_set_query("CALL proc_test1()", dialect));
assert!(prefers_text_protocol_query("CALL proc_test1()", dialect));
}
#[test]
fn starrocks_admin_show_queries_are_treated_as_result_sets() {
let sql = "ADMIN SHOW FRONTEND CONFIG LIKE '%default_replication_num%'";
let dialect = MySqlQueryDialect::for_connection(DatabaseType::StarRocks, None);
assert!(is_result_set_query(sql, dialect));
assert!(requires_text_protocol_query(sql, dialect));
}
#[test]
fn doris_admin_show_queries_are_treated_as_result_sets() {
let sql = "ADMIN SHOW FRONTEND CONFIG LIKE '%default_replication_num%'";
let dialect = MySqlQueryDialect::for_connection(DatabaseType::Doris, None);
assert!(is_result_set_query(sql, dialect));
assert!(requires_text_protocol_query(sql, dialect));
}
#[test]
fn mysql_starrocks_profile_admin_show_queries_are_treated_as_result_sets() {
let sql = "ADMIN SHOW FRONTEND CONFIG LIKE '%default_replication_num%'";
let dialect = MySqlQueryDialect::for_connection(DatabaseType::Mysql, Some("starrocks"));
assert!(is_result_set_query(sql, dialect));
assert!(requires_text_protocol_query(sql, dialect));
}
#[test]
fn mysql_admin_show_queries_are_not_treated_as_result_sets() {
let sql = "ADMIN SHOW FRONTEND CONFIG LIKE '%default_replication_num%'";
let dialect = MySqlQueryDialect::for_connection(DatabaseType::Mysql, None);
assert!(!is_result_set_query(sql, dialect));
assert!(!requires_text_protocol_query(sql, dialect));
}
#[test]
fn admin_show_detection_skips_leading_comments() {
let sql = "-- inspect FE config\nADMIN /* StarRocks */ SHOW FRONTEND CONFIG";
let dialect = MySqlQueryDialect::for_connection(DatabaseType::StarRocks, None);
assert!(is_result_set_query(sql, dialect));
assert!(requires_text_protocol_query(sql, dialect));
}
#[test]
fn admin_set_queries_are_not_treated_as_result_sets() {
let dialect = MySqlQueryDialect::for_connection(DatabaseType::StarRocks, None);
assert!(!is_result_set_query("ADMIN SET FRONTEND CONFIG ('default_replication_num' = '1')", dialect));
}
#[test]
fn numeric_metadata_accepts_unsigned_information_schema_values() {
assert_eq!(numeric_metadata_u64_to_i32(Some(65)), Some(65));
}
#[test]
fn numeric_metadata_ignores_values_outside_frontend_range() {
assert_eq!(numeric_metadata_u64_to_i32(Some(i32::MAX as u64 + 1)), None);
assert_eq!(numeric_metadata_u64_to_i32(None), None);
}
#[test]
fn mysql_list_tables_objects_sql_includes_timestamps() {
let sql = list_tables_objects_sql("app", None, None, None);
assert!(sql.contains("information_schema.TABLES"));
assert!(!sql.contains("information_schema.ROUTINES"));
assert!(!sql.contains("UNION"));
assert!(sql.contains("CREATE_TIME"));
assert!(sql.contains("UPDATE_TIME"));
}
#[test]
fn mysql_list_tables_sql_applies_filter_limit_and_offset() {
let sql = list_tables_sql("app", Some("user_%"), Some(101), Some(200), None, None);
assert!(sql.contains("FROM information_schema.TABLES"));
assert!(sql.contains("TABLE_SCHEMA = 'app'"));
assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%user\\\\_\\\\%%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_COMMENT) LIKE '%user\\\\_\\\\%%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%u%s%e%r%\\\\_%\\\\%%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_COMMENT) LIKE '%u%s%e%r%\\\\_%\\\\%%' ESCAPE '\\\\'"));
assert!(sql.contains("ORDER BY TABLE_NAME"));
assert!(sql.contains("LIMIT 101"));
assert!(sql.contains("OFFSET 200"));
}
#[test]
fn mysql_list_tables_sql_adds_fuzzy_filter_pattern() {
let sql = list_tables_sql("app", Some("sysu"), Some(100), None, None, None);
assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%sysu%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_COMMENT) LIKE '%sysu%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%s%y%s%u%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_COMMENT) LIKE '%s%y%s%u%' ESCAPE '\\\\'"));
}
#[test]
fn mysql_list_tables_sql_skips_fuzzy_filter_for_single_character() {
let sql = list_tables_sql("app", Some("u"), Some(100), None, None, None);
assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%u%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_COMMENT) LIKE '%u%' ESCAPE '\\\\'"));
assert_eq!(sql.matches("LOWER(TABLE_NAME) LIKE").count(), 1);
assert_eq!(sql.matches("LOWER(TABLE_COMMENT) LIKE").count(), 1);
assert!(!sql.contains(" OR LOWER(TABLE_NAME) LIKE"));
}
#[test]
fn mysql_list_tables_sql_filters_table_type_before_pagination() {
let tables = vec!["TABLE".to_string()];
let table_sql = list_tables_sql("app", None, Some(1000), None, Some(&tables), None);
assert!(table_sql.contains("TABLE_TYPE <> 'VIEW'"));
assert!(table_sql.find("TABLE_TYPE <> 'VIEW'") < table_sql.find("ORDER BY TABLE_NAME"));
assert!(table_sql.find("ORDER BY TABLE_NAME") < table_sql.find("LIMIT 1000"));
let views = vec!["VIEW".to_string()];
let view_sql = list_tables_sql("app", None, Some(1000), None, Some(&views), None);
assert!(view_sql.contains("TABLE_TYPE = 'VIEW'"));
}
#[test]
fn mysql_filtered_show_fallback_is_server_bounded() {
let tables_sql = show_tables_filtered_sql("app", true, Some("missing_%"), &[]);
let status_sql = show_table_status_sql("app", Some("missing_%"));
assert!(tables_sql.starts_with("SHOW FULL TABLES FROM `app` WHERE "));
assert!(tables_sql.contains("`Tables_in_app` LIKE"));
assert!(tables_sql.contains("missing"));
assert!(tables_sql.contains("ESCAPE"));
assert!(status_sql.starts_with("SHOW TABLE STATUS FROM `app` WHERE "));
assert!(status_sql.contains("Name LIKE"));
assert!(status_sql.contains("Comment LIKE"));
assert!(status_sql.contains("missing"));
assert!(status_sql.contains("ESCAPE"));
assert!(!tables_sql.eq(&show_tables_filtered_sql("app", true, None, &[])));
}
#[test]
fn mysql_filtered_show_fallback_attempts_bare_show_after_syntax_errors() {
let attempts = show_tables_query_attempts("app", Some("orders"), &[]);
assert_eq!(attempts.len(), 4);
assert!(attempts[0].server_filtered);
assert!(attempts[0].sql.starts_with("SHOW FULL TABLES FROM `app` WHERE "));
assert!(attempts[1].server_filtered);
assert!(attempts[1].sql.starts_with("SHOW TABLES FROM `app` WHERE "));
assert!(!attempts[2].server_filtered);
assert_eq!(attempts[2].sql, "SHOW FULL TABLES FROM `app`");
assert!(!attempts[3].server_filtered);
assert_eq!(attempts[3].sql, "SHOW TABLES FROM `app`");
}
#[test]
fn mysql_unfiltered_show_avoids_duplicate_attempts() {
let attempts = show_tables_query_attempts("app", None, &[]);
assert_eq!(attempts.len(), 2);
assert_eq!(attempts[0].sql, "SHOW FULL TABLES FROM `app`");
assert_eq!(attempts[1].sql, "SHOW TABLES FROM `app`");
assert!(attempts.iter().all(|attempt| !attempt.server_filtered));
}
#[test]
fn mysql_unfiltered_status_fallback_is_filtered_locally_once() {
let status = HashMap::from([
(
"orders".to_string(),
TableStatusMeta { comment: Some("purchase history".to_string()), ..Default::default() },
),
("users".to_string(), TableStatusMeta::default()),
]);
let filtered = filter_table_status_fallback(status, Some("purchase"));
assert_eq!(filtered.keys().map(String::as_str).collect::<Vec<_>>(), vec!["orders"]);
}
#[test]
fn mysql_list_tables_sql_applies_table_name_filter_before_pagination() {
let filter = TableNameFilter {
include_patterns: vec!["ads_cp%".to_string(), "user_%".to_string()],
exclude_patterns: vec!["%_bak".to_string()],
};
let sql = list_tables_sql("app", None, Some(100), Some(200), None, Some(&filter));
assert!(sql.contains("LOWER(TABLE_NAME) LIKE 'ads_cp%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_NAME) LIKE 'user_%' ESCAPE '\\\\'"));
assert!(sql.contains("LOWER(TABLE_NAME) NOT LIKE '%_bak' ESCAPE '\\\\'"));
assert!(sql.find("LOWER(TABLE_NAME) LIKE").unwrap() < sql.find("ORDER BY TABLE_NAME").unwrap());
assert!(sql.find("ORDER BY TABLE_NAME").unwrap() < sql.find("LIMIT 100").unwrap());
}
#[test]
fn mysql_show_tables_fallback_applies_filter_type_limit_and_offset() {
let rows = vec![
TableInfo {
name: "audit_2024".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
TableInfo {
name: "audit_view".to_string(),
table_type: "VIEW".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
TableInfo {
name: "audit_2025".to_string(),
table_type: "BASE TABLE".to_string(),
comment: Some("purchase order history".to_string()),
parent_schema: None,
parent_name: None,
},
];
let filtered =
filter_list_tables_fallback(rows, Some("audit"), Some(1), Some(1), Some(&["TABLE".to_string()]), None);
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["audit_2025"]);
let rows = vec![TableInfo {
name: "t_0001".to_string(),
table_type: "BASE TABLE".to_string(),
comment: Some("food orders".to_string()),
parent_schema: None,
parent_name: None,
}];
let filtered = filter_list_tables_fallback(rows, Some("ood"), None, None, Some(&["TABLE".to_string()]), None);
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["t_0001"]);
}
#[test]
fn mysql_object_browser_fallback_keeps_tables_views_and_routines_by_default() {
let table_objects = vec![mysql_test_object("orders", "TABLE"), mysql_test_object("orders_view", "VIEW")];
let mut objects = filter_table_objects_fallback(table_objects, None, None, None);
objects.push(mysql_test_object("refresh_orders", "PROCEDURE"));
assert_eq!(
objects.iter().map(|object| object.object_type.as_str()).collect::<Vec<_>>(),
vec!["TABLE", "VIEW", "PROCEDURE"]
);
}
#[test]
fn mysql_object_browser_routine_only_does_not_use_table_fallback() {
let object_types = vec!["PROCEDURE".to_string(), "FUNCTION".to_string()];
assert!(!wants_table_objects(Some(&object_types)));
assert!(wants_routine_objects(Some(&object_types)));
assert!(filter_table_objects_fallback(
vec![mysql_test_object("orders", "TABLE")],
Some(&object_types),
None,
None,
)
.is_empty());
}
#[test]
fn mysql_object_browser_fallback_filters_type_limit_and_offset() {
let objects = vec![
mysql_test_object("a_table", "TABLE"),
mysql_test_object("b_view", "VIEW"),
mysql_test_object("c_table", "TABLE"),
];
let object_types = vec!["TABLE".to_string()];
let filtered = filter_table_objects_fallback(objects, Some(&object_types), Some(1), Some(1));
assert_eq!(filtered.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), vec!["c_table"]);
}
#[test]
fn starrocks_materialized_views_are_classified_without_duplicating_tables() {
let mut tables = vec![
TableInfo {
name: "orders".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
TableInfo {
name: "orders_view".to_string(),
table_type: "VIEW".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
TableInfo {
name: "orders_mv".to_string(),
table_type: "VIEW".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
];
let materialized_views = HashSet::from(["orders_mv".to_string(), "orders_mv".to_string()]);
merge_starrocks_materialized_views(&mut tables, Ok(materialized_views), "analytics");
assert_eq!(tables.len(), 3);
assert_eq!(
tables.iter().map(|table| (table.name.as_str(), table.table_type.as_str())).collect::<Vec<_>>(),
vec![("orders", "BASE TABLE"), ("orders_view", "VIEW"), ("orders_mv", "MATERIALIZED_VIEW")]
);
}
#[test]
fn starrocks_async_materialized_views_reported_as_base_table_are_reclassified() {
// Async materialized views (StarRocks >= 2.5) appear as `BASE TABLE` in
// `SHOW FULL TABLES`. Classification must trust the
// `information_schema.materialized_views` source.
let mut tables = vec![
TableInfo {
name: "orders".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
TableInfo {
name: "orders_async_mv".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
];
let materialized_views = HashSet::from(["orders_async_mv".to_string()]);
merge_starrocks_materialized_views(&mut tables, Ok(materialized_views), "analytics");
assert_eq!(
tables.iter().map(|table| (table.name.as_str(), table.table_type.as_str())).collect::<Vec<_>>(),
vec![("orders", "BASE TABLE"), ("orders_async_mv", "MATERIALIZED_VIEW")]
);
}
#[test]
fn starrocks_materialized_view_lookup_failure_keeps_base_types() {
let mut tables = vec![TableInfo {
name: "orders_mv".to_string(),
table_type: "VIEW".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
}];
merge_starrocks_materialized_views(&mut tables, Err("permission denied".to_string()), "analytics");
assert_eq!(tables[0].table_type, "VIEW");
}
#[test]
fn starrocks_sync_mv_absent_from_show_full_tables_is_appended_from_information_schema() {
// StarRocks versions predating starrocks/starrocks#73396 (merged
// 2026-05-19) report sync MVs as "not registered as separate Tables",
// so SHOW FULL TABLES omits them. The merger must union names from
// information_schema.materialized_views so the sidebar and DDL path
// still resolve them.
let mut tables = vec![TableInfo {
name: "orders".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
}];
let materialized_views = HashSet::from([
"orders_mv".to_string(), // already present (reclassify path)
"daily_orders_mv".to_string(), // absent from SHOW FULL TABLES (union path)
]);
merge_starrocks_materialized_views(&mut tables, Ok(materialized_views), "analytics");
assert_eq!(tables.len(), 3);
assert_eq!(
tables.iter().map(|table| (table.name.as_str(), table.table_type.as_str())).collect::<Vec<_>>(),
vec![
("orders", "BASE TABLE"),
("daily_orders_mv", "MATERIALIZED_VIEW"),
("orders_mv", "MATERIALIZED_VIEW"),
]
);
}
#[test]
fn starrocks_materialized_view_query_is_scoped_to_database() {
let sql = starrocks_materialized_views_sql("tenant's analytics");
assert_eq!(
sql,
"SELECT TABLE_NAME FROM information_schema.materialized_views WHERE TABLE_SCHEMA = 'tenant\\'s analytics'"
);
}
#[test]
fn mysql_materialized_view_definition_fallback_is_scoped_to_db_and_name() {
// StarRocks predating PR 73396 (merged 2026-05-19) rejects
// `SHOW CREATE MATERIALIZED VIEW` for sync MVs with "Table not found"
// because sync MVs are not registered as separate Tables. The fallback
// path queries information_schema.materialized_views directly. The
// regression guards the SQL shape and the value escaping used by that
// fallback so the wire format isn't accidentally regressed.
assert_eq!(
mysql_materialized_view_definition_sql("shop", "daily_sales_mv"),
"SELECT MATERIALIZED_VIEW_DEFINITION FROM information_schema.materialized_views WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'daily_sales_mv' LIMIT 1"
);
assert_eq!(
mysql_materialized_view_definition_sql("tenant's analytics", "weird'name"),
"SELECT MATERIALIZED_VIEW_DEFINITION FROM information_schema.materialized_views WHERE TABLE_SCHEMA = 'tenant\\'s analytics' AND TABLE_NAME = 'weird\\'name' LIMIT 1"
);
}
#[test]
fn starrocks_object_conversion_preserves_table_view_and_materialized_view_types() {
let tables = vec![
TableInfo {
name: "orders".to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
TableInfo {
name: "orders_view".to_string(),
table_type: "VIEW".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
TableInfo {
name: "orders_mv".to_string(),
table_type: "MATERIALIZED_VIEW".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
},
];
let objects = table_infos_to_objects(tables, &HashMap::new(), "analytics");
assert_eq!(
objects.iter().map(|object| (object.name.as_str(), object.object_type.as_str())).collect::<Vec<_>>(),
vec![("orders", "TABLE"), ("orders_view", "VIEW"), ("orders_mv", "MATERIALIZED_VIEW")]
);
}
#[test]
fn mysql_table_comment_sql_targets_single_table() {
let sql = table_comment_sql("app", "users");
assert!(sql.contains("SELECT TABLE_COMMENT"));
assert!(sql.contains("TABLE_SCHEMA = 'app'"));
assert!(sql.contains("TABLE_NAME = 'users'"));
assert!(sql.contains("TABLE_TYPE <> 'VIEW'"));
assert!(sql.contains("LIMIT 1"));
assert!(!sql.contains("ORDER BY"));
}
#[test]
fn mysql_database_infos_filter_blank_names_and_keep_catalogless_marker() {
let regular = database_infos_from_names(vec!["".to_string(), " app ".to_string(), "mysql".to_string()], true);
assert_eq!(regular.iter().map(|db| db.name.as_str()).collect::<Vec<_>>(), vec!["app", "mysql"]);
let catalogless = database_infos_from_names(vec!["".to_string(), " ".to_string()], true);
assert_eq!(catalogless.iter().map(|db| db.name.as_str()).collect::<Vec<_>>(), vec![""]);
let no_marker = database_infos_from_names(vec!["".to_string()], false);
assert!(no_marker.is_empty());
}
#[test]
fn mysql_show_metadata_sql_supports_catalogless_services() {
assert_eq!(show_tables_filtered_sql("", true, None, &[]), "SHOW FULL TABLES");
assert_eq!(show_tables_filtered_sql("", false, None, &[]), "SHOW TABLES");
assert_eq!(show_tables_filtered_sql("app", true, None, &[]), "SHOW FULL TABLES FROM `app`");
assert_eq!(show_columns_sql("", "idx", true), "SHOW FULL COLUMNS FROM `idx`");
assert_eq!(show_columns_sql("app", "idx", false), "SHOW COLUMNS FROM `app`.`idx`");
}
#[test]
fn mysql_list_routines_sql_is_independent_of_tables() {
let sql = list_routines_sql("app", None, None, None);
assert!(sql.contains("information_schema.ROUTINES"));
assert!(!sql.contains("information_schema.TABLES"));
assert!(!sql.contains("UNION"));
assert!(sql.contains("'PROCEDURE'"));
assert!(sql.contains("'FUNCTION'"));
assert!(!sql.contains("LAST_ALTERED"));
assert!(!sql.contains("CREATED AS created_at"));
}
#[test]
fn mysql_list_routines_sql_honors_requested_type_and_paging() {
let object_types = vec!["PROCEDURE".to_string()];
let sql = list_routines_sql("app", Some(&object_types), Some(101), Some(200));
assert!(sql.contains("ROUTINE_TYPE IN ('PROCEDURE')"));
assert!(!sql.contains("'FUNCTION'"));
assert!(sql.ends_with("LIMIT 101 OFFSET 200"));
}
#[test]
fn mysql_list_tables_objects_sql_honors_requested_type_and_paging() {
let object_types = vec!["VIEW".to_string()];
let sql = list_tables_objects_sql("app", Some(&object_types), Some(51), Some(100));
assert!(sql.contains("TABLE_TYPE = 'VIEW'"));
assert!(sql.ends_with("LIMIT 51 OFFSET 100"));
}
#[test]
fn mysql_object_query_only_pages_within_one_metadata_source() {
assert!(object_query_supports_paging(Some(&["PROCEDURE".to_string()])));
assert!(object_query_supports_paging(Some(&["TABLE".to_string(), "VIEW".to_string()])));
assert!(!object_query_supports_paging(Some(&["TABLE".to_string(), "PROCEDURE".to_string()])));
assert!(!object_query_supports_paging(None));
}
#[test]
fn mysql_completion_triggers_sql_lists_database_triggers() {
let sql = list_completion_triggers_sql("app");
assert!(sql.contains("information_schema.TRIGGERS"));
assert!(sql.contains("'TRIGGER' AS object_type"));
assert!(sql.contains("EVENT_OBJECT_TABLE AS parent_name"));
assert!(sql.contains("TRIGGER_SCHEMA = 'app'"));
}
#[test]
fn mysql_completion_like_pattern_uses_prefix_by_default() {
assert_eq!(mysql_completion_like_pattern("Temp", Some(&CompletionAssistantMatchMode::Prefix)), "Temp%");
assert_eq!(mysql_completion_like_pattern("Temp", Some(&CompletionAssistantMatchMode::Contains)), "%Temp%");
assert_eq!(
mysql_completion_like_pattern("order_100%", Some(&CompletionAssistantMatchMode::Prefix)),
"order\\_100\\%%"
);
}
#[test]
fn mysql_completion_sql_filters_before_limit() {
let table_sql = mysql_completion_tables_sql(
"app",
"Temp%",
&[CompletionAssistantObjectKind::Table, CompletionAssistantObjectKind::View],
100,
);
let routine_sql =
mysql_completion_routines_sql("app", "%audit%", &[CompletionAssistantObjectKind::Routine], 50);
let column_sql = mysql_completion_columns_sql("app", "users", "id%", 25);
assert!(table_sql.contains("TABLE_NAME LIKE 'Temp%' ESCAPE '\\\\'"));
assert!(table_sql.contains("TABLE_TYPE IN ('BASE TABLE','SYSTEM VERSIONED','VIEW')"));
assert!(table_sql.contains("ORDER BY TABLE_NAME LIMIT 100"));
assert!(routine_sql.contains("ROUTINE_NAME LIKE '%audit%' ESCAPE '\\\\'"));
assert!(routine_sql.contains("ROUTINE_TYPE IN ('PROCEDURE','FUNCTION')"));
assert!(column_sql.contains("COLUMN_NAME LIKE 'id%' ESCAPE '\\\\'"));
assert!(column_sql.contains("ORDER BY ORDINAL_POSITION LIMIT 25"));
}
#[test]
fn mysql_columns_sql_uses_column_key_and_table_default_collation() {
let sql = columns_sql("app", "users");
assert!(sql.contains("information_schema.COLUMNS"));
assert!(sql.contains("information_schema.TABLES"));
assert!(sql.contains("t.TABLE_COLLATION"));
assert!(!sql.contains("KEY_COLUMN_USAGE"));
assert!(!sql.contains("CONSTRAINT_NAME = 'PRIMARY'"));
assert!(sql.contains("c.COLUMN_KEY"));
assert!(sql.contains("c.DATA_TYPE"));
assert!(sql.contains("c.COLUMN_TYPE"));
assert!(!sql.contains("COLLATE"));
assert!(!sql.contains("AS ENUM_VALUES"));
}
#[test]
fn mysql_column_charset_metadata_clears_values_matching_table_default() {
let mut columns = vec![
ColumnInfo {
name: "inherited_name".to_string(),
character_set: Some("utf8mb4".to_string()),
collation: Some("utf8mb4_unicode_ci".to_string()),
..Default::default()
},
ColumnInfo {
name: "explicit_other".to_string(),
character_set: Some("latin1".to_string()),
collation: Some("latin1_bin".to_string()),
..Default::default()
},
ColumnInfo { name: "numeric_value".to_string(), ..Default::default() },
];
normalize_mysql_column_charset_metadata(&mut columns, Some("utf8mb4_unicode_ci"));
assert_eq!((columns[0].character_set.as_deref(), columns[0].collation.as_deref()), (None, None));
assert_eq!(columns[1].character_set.as_deref(), Some("latin1"));
assert_eq!(columns[1].collation.as_deref(), Some("latin1_bin"));
assert_eq!((columns[2].character_set.as_deref(), columns[2].collation.as_deref()), (None, None));
}
#[test]
fn mysql_column_charset_metadata_preserves_values_without_table_default() {
let mut columns = vec![ColumnInfo {
name: "name".to_string(),
character_set: Some("utf8mb4".to_string()),
collation: Some("utf8mb4_unicode_ci".to_string()),
..Default::default()
}];
normalize_mysql_column_charset_metadata(&mut columns, None);
assert_eq!(columns[0].character_set.as_deref(), Some("utf8mb4"));
assert_eq!(columns[0].collation.as_deref(), Some("utf8mb4_unicode_ci"));
}
#[test]
fn parse_mysql_enum_values_preserves_mysql_literal_edges() {
assert_eq!(
parse_mysql_enum_values("enum('pending','active','archived')"),
Some(vec!["pending".to_string(), "active".to_string(), "archived".to_string()])
);
assert_eq!(parse_mysql_enum_values("ENUM('','a')"), Some(vec!["".to_string(), "a".to_string()]));
assert_eq!(parse_mysql_enum_values("enum('x'',''y','z')"), Some(vec!["x','y".to_string(), "z".to_string()]));
assert_eq!(
parse_mysql_enum_values(r#"enum('it''s','quote\"d','back\\slash')"#),
Some(vec!["it's".to_string(), "quote\"d".to_string(), "back\\slash".to_string()])
);
assert_eq!(parse_mysql_enum_values("varchar(255)"), None);
}
#[test]
fn mysql_largeint_uses_lossless_integer_decoding() {
assert!(is_mysql_lossless_integer_type("LARGEINT"));
}
fn mysql_test_column(
column_type: ColumnType,
character_set: u16,
flags: ColumnFlags,
column_length: u32,
) -> mysql_async::Column {
mysql_async::Column::new(column_type)
.with_character_set(character_set)
.with_flags(flags)
.with_column_length(column_length)
}
#[test]
fn mysql_binary_preview_keeps_binary_collation_varchar_as_text() {
let column = mysql_test_column(ColumnType::MYSQL_TYPE_VAR_STRING, 45, ColumnFlags::BINARY_FLAG, 64);
assert_eq!(mysql_bytes_to_json(b"SN-A0001".to_vec(), &column), serde_json::json!("SN-A0001"));
}
#[test]
fn mysql_binary_preview_renders_binary_and_varbinary_like_navicat_text_preview() {
let binary_column = mysql_test_column(ColumnType::MYSQL_TYPE_STRING, 63, ColumnFlags::BINARY_FLAG, 8);
let varbinary_column = mysql_test_column(ColumnType::MYSQL_TYPE_VAR_STRING, 63, ColumnFlags::BINARY_FLAG, 8);
assert_eq!(mysql_bytes_to_json(b"150010\0\0".to_vec(), &binary_column), serde_json::json!("150010"));
assert_eq!(mysql_bytes_to_json(b"150010".to_vec(), &varbinary_column), serde_json::json!("150010"));
}
#[test]
fn mysql_binary_preview_falls_back_to_hex_for_unprintable_bytes() {
let binary_column = mysql_test_column(ColumnType::MYSQL_TYPE_STRING, 63, ColumnFlags::BINARY_FLAG, 8);
let varbinary_column = mysql_test_column(ColumnType::MYSQL_TYPE_VAR_STRING, 63, ColumnFlags::BINARY_FLAG, 8);
assert_eq!(mysql_bytes_to_json(vec![0x01, 0x02, 0x03, 0x04], &binary_column), serde_json::json!("0x01020304"));
assert_eq!(
mysql_bytes_to_json(vec![0xde, 0xad, 0xbe, 0xef], &varbinary_column),
serde_json::json!("0xdeadbeef")
);
}
#[test]
fn mysql_binary_preview_uses_charset_to_separate_blob_from_text() {
let text_column = mysql_test_column(ColumnType::MYSQL_TYPE_BLOB, 45, ColumnFlags::empty(), 65_535);
let blob_column = mysql_test_column(ColumnType::MYSQL_TYPE_BLOB, 63, ColumnFlags::BLOB_FLAG, 65_535);
assert_eq!(mysql_bytes_to_json(b"hello".to_vec(), &text_column), serde_json::json!("hello"));
assert_eq!(mysql_bytes_to_json(vec![0x00, 0x01, 0xab, 0xff], &blob_column), serde_json::json!("0x0001abff"));
}
#[test]
fn mysql_bit_preview_uses_boolean_or_bit_string_text() {
let bit_one = mysql_test_column(ColumnType::MYSQL_TYPE_BIT, 63, ColumnFlags::UNSIGNED_FLAG, 1);
let bit_eight = mysql_test_column(ColumnType::MYSQL_TYPE_BIT, 63, ColumnFlags::UNSIGNED_FLAG, 8);
assert_eq!(mysql_bit_value_to_string(&[1], &bit_one), "1");
assert_eq!(mysql_bit_value_to_string(&[0b1010_1010], &bit_eight), "10101010");
}
#[test]
fn mysql_column_key_marks_primary() {
let column_key = "PRI";
let is_pk = column_key.eq_ignore_ascii_case("PRI");
assert!(is_pk);
}
#[test]
fn mysql_management_show_queries_use_text_protocol() {
assert!(requires_text_protocol_query("SHOW PROCESSLIST", MySqlQueryDialect::default()));
assert!(requires_text_protocol_query("show full processlist", MySqlQueryDialect::default()));
assert!(requires_text_protocol_query("SHOW SLAVE STATUS", MySqlQueryDialect::default()));
assert!(requires_text_protocol_query("show replica status", MySqlQueryDialect::default()));
assert!(requires_text_protocol_query("SHOW GRANTS", MySqlQueryDialect::default()));
assert!(requires_text_protocol_query("SHOW GRANTS FOR 'repl'@'%'", MySqlQueryDialect::default()));
assert!(!requires_text_protocol_query("SHOW TABLES", MySqlQueryDialect::default()));
assert!(!requires_text_protocol_query("SELECT * FROM users", MySqlQueryDialect::default()));
}
#[test]
fn mysql_user_result_sets_prefer_text_protocol() {
let dialect = MySqlQueryDialect::default();
assert!(prefers_text_protocol_query("SELECT * FROM users", dialect));
assert!(prefers_text_protocol_query("WITH recent AS (SELECT 1 AS id) SELECT id FROM recent", dialect));
assert!(prefers_text_protocol_query("SHOW TABLES", dialect));
assert!(!prefers_text_protocol_query("UPDATE users SET name = 'Ada' WHERE id = 1", dialect));
}
#[test]
fn mysql_text_result_sets_use_buffered_collection_for_bounded_page_queries() {
assert!(should_collect_text_result_set("SELECT * FROM users LIMIT 100;", 100, Some(100)));
assert!(should_collect_text_result_set("SELECT * FROM users ORDER BY id LIMIT 25 OFFSET 50;", 100, Some(100)));
assert!(should_collect_text_result_set("SELECT * FROM users LIMIT 20, 50;", 100, Some(100)));
}
#[test]
fn mysql_text_result_sets_keep_streaming_when_unbounded_or_too_large() {
assert!(!should_collect_text_result_set("SELECT * FROM users", 100, Some(100)));
assert!(!should_collect_text_result_set("SELECT * FROM users LIMIT 1000000", 100, Some(100)));
assert!(!should_collect_text_result_set("SELECT * FROM users LIMIT 100", 100, None));
assert!(!should_collect_text_result_set("SELECT * FROM (SELECT * FROM audit LIMIT 100) t", 100, Some(100)));
}
#[test]
fn mysql_binary_decode_parse_errors_retry_with_text_protocol() {
assert!(mysql_error_should_retry_with_text_protocol(
"Input/output error: can't parse: buf doesn't have enough data"
));
}
#[test]
fn mysql_timestamp_default_null_ddl_enables_explicit_defaults() {
let create_sql = r#"
CREATE TABLE `referral_record` (
`id` BINARY(16) NOT NULL,
`created_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
`deleted_at` TIMESTAMP(6) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB
"#;
assert!(should_enable_explicit_timestamp_defaults(create_sql));
assert!(should_enable_explicit_timestamp_defaults(
"ALTER TABLE referral_record ADD deleted_at TIMESTAMP DEFAULT NULL"
));
assert!(!should_enable_explicit_timestamp_defaults("CREATE TABLE t (deleted_at DATETIME(6) DEFAULT NULL)"));
assert!(!should_enable_explicit_timestamp_defaults("SELECT 'TIMESTAMP DEFAULT NULL'"));
assert_eq!(explicit_timestamp_defaults_sql(true), "SET SESSION explicit_defaults_for_timestamp = ON");
assert_eq!(explicit_timestamp_defaults_sql(false), "SET SESSION explicit_defaults_for_timestamp = OFF");
}
#[test]
fn mysql_tls_session_close_errors_retry_without_ssl() {
let error = "MySQL connection failed: error communicating with database: \
encountered error while attempting to establish a TLS connection: \
server closed session with no notification";
assert!(mysql_error_should_retry_without_ssl(error));
}
#[test]
fn mysql_server_without_ssl_capability_retries_without_ssl() {
let error =
"MySQL connection failed: Driver error: `Client asked for SSL but server does not have this capability'";
assert!(mysql_error_should_retry_without_ssl(error));
}
#[test]
fn mysql_packet_out_of_order_can_retry_without_ssl() {
let error = "MySQL connection failed: Input/output error: Input/output error: packet out of order";
assert!(mysql_error_should_retry_without_ssl(error));
assert!(!mysql_error_should_retry_with_legacy_eof(error));
}
#[test]
fn mysql_packets_out_of_sync_retries_with_legacy_eof() {
let error = "MySQL connection failed: Input/output error: Input/output error: Packets out of sync";
assert!(mysql_error_should_retry_with_legacy_eof(error));
assert!(!mysql_error_should_retry_without_ssl(error));
}
#[test]
fn mysql_async_builder_can_disable_deprecated_eof_protocol() {
let opts = mysql_async::Opts::from(mysql_async::OptsBuilder::default().deprecate_eof(false));
assert!(!opts.deprecate_eof());
}
#[test]
fn mysql_tcp_keepalive_uses_milliseconds_not_seconds() {
assert_eq!(MYSQL_TCP_KEEPALIVE_MS, 30_000);
}
#[test]
fn mysql_async_builder_host_strips_ipv6_url_brackets() {
let opts = mysql_async::Opts::from_url("mysql://root:secret@[2001:db8::1]:3306/app").unwrap();
assert_eq!(opts.ip_or_hostname(), "[2001:db8::1]");
assert_eq!(mysql_async_tcp_host(opts.ip_or_hostname()), "2001:db8::1");
let builder_opts = mysql_async::Opts::from(
mysql_async::OptsBuilder::from_opts(opts).ip_or_hostname(mysql_async_tcp_host("[2001:db8::1]").to_string()),
);
assert_eq!(builder_opts.ip_or_hostname(), "2001:db8::1");
assert_eq!(builder_opts.tcp_port(), 3306);
}
#[test]
fn mysql_async_builder_host_only_strips_valid_ipv6_literals() {
assert_eq!(mysql_async_tcp_host("2001:db8::1"), "2001:db8::1");
assert_eq!(mysql_async_tcp_host("[mysql.example.com]"), "[mysql.example.com]");
assert_eq!(mysql_async_tcp_host("mysql.example.com"), "mysql.example.com");
}
#[test]
fn mysql_tls_url_strips_client_identity_params_before_driver_parse() {
let dir = std::env::temp_dir();
let cert = dir.join(format!("dbx-mysql-client-cert-{}.pem", std::process::id()));
let key = dir.join(format!("dbx-mysql-client-key-{}.pem", std::process::id()));
std::fs::write(&cert, "not a real cert").unwrap();
std::fs::write(&key, "not a real key").unwrap();
let url = format!(
"mysql://root:secret@localhost/test?require_ssl=true&ssl-cert={}&ssl-key={}&charset=utf8mb4",
cert.display(),
key.display()
);
let parsed = mysql_tls_url(&url).unwrap();
assert_eq!(parsed.url, "mysql://root:secret@localhost/test?require_ssl=true&charset=utf8mb4");
assert_eq!(parsed.files.sslcert.as_deref(), Some(cert.to_str().unwrap()));
assert_eq!(parsed.files.sslkey.as_deref(), Some(key.to_str().unwrap()));
mysql_async::Opts::from_url(&mysql_async_url(&parsed.url)).unwrap();
let _ = std::fs::remove_file(cert);
let _ = std::fs::remove_file(key);
}
#[test]
fn mysql_tls_rejects_unpaired_client_cert_and_key() {
let files = MySqlTlsFiles { sslcert: Some("/tmp/client.crt".to_string()), sslkey: None };
let error = mysql_ssl_opts(None, "mysql://root@localhost/db?require_ssl=true", None, &files).unwrap_err();
assert!(error.contains("ssl-key"));
}
#[test]
fn mysql_tls_client_identity_requires_ssl() {
assert!(mysql_url_requires_ssl("mysql://root@localhost/db?ssl-cert=/tmp/client.crt&ssl-key=/tmp/client.key"));
}
#[test]
fn mysql_preferred_tls_attempts_ssl_without_requiring_it() {
let url = "mysql://root@localhost/db?ssl-mode=preferred&charset=utf8mb4";
assert!(!mysql_url_requires_ssl(url));
assert!(mysql_url_attempts_ssl(url));
assert_eq!(
ssl_fallback_url(url),
Some("mysql://root@localhost/db?ssl-mode=disabled&charset=utf8mb4".to_string())
);
assert!(mysql_ssl_opts(None, url, None, &MySqlTlsFiles::default()).unwrap().is_some());
}
#[test]
fn mysql_preferred_tls_handles_sslmode_prefer_alias() {
let url = "mysql://root@localhost/db?sslmode=prefer&charset=utf8mb4#session";
assert!(!mysql_url_requires_ssl(url));
assert!(mysql_url_attempts_ssl(url));
assert_eq!(
ssl_fallback_url(url),
Some("mysql://root@localhost/db?ssl-mode=disabled&charset=utf8mb4#session".to_string())
);
assert_eq!(
ssl_fallback_url("mysql://root@localhost/db#session"),
Some("mysql://root@localhost/db?ssl-mode=disabled#session".to_string())
);
}
#[test]
fn mysql_unknown_error_can_retry_with_text_protocol() {
let error = "error returned from database: 1105 (HY000): Unknown error";
assert!(mysql_error_should_retry_with_text_protocol(error));
}
#[test]
fn mysql_unsupported_prepare_command_can_retry_with_text_protocol() {
let error = "ERROR PX000 (3000): [a2jupsonbbv6zai1gomo5whu36ndqy] Unsupported command: COM_STMT_PREPARE";
assert!(mysql_error_should_retry_with_text_protocol(error));
}
#[test]
fn mysql_reprepared_statement_error_can_retry_with_text_protocol() {
let error = "Server error: ERROR HY000 (1615): Prepared statement needs to be re-prepared";
assert!(mysql_error_should_retry_with_text_protocol(error));
}
#[test]
fn mysql_group_concat_setup_error_retries_without_session_variable() {
let error = "MySQL connection failed: Server error: `ERROR HY000 (1193): Unknown system variable,stmt:SET @@group_concat_max_len = 1048576'";
assert_eq!(
mysql_group_concat_setup_fallback_mode(MySqlSetupMode::Standard, error),
Some(MySqlSetupMode::Compatible)
);
}
#[test]
fn mysql_cnch_group_concat_syntax_error_retries_without_session_variable() {
let error = "MySQL connection failed: Server error: `ERROR HY000 (1105): unknown error: Error 62 (HY000): Code: 62, e.displayText() = DB::Exception: host = cnch-server-2: Syntax error: failed at position 13 ('group_concat_max_len'): group_concat_max_len = 1048576. Expected one of: Dot, token, Equals SQLSTATE: 42000 (version 21.8.7.1)'";
assert_eq!(
mysql_group_concat_setup_fallback_mode(MySqlSetupMode::Standard, error),
Some(MySqlSetupMode::Compatible)
);
}
#[test]
fn mysql_index_metadata_query_has_expression_compatibility_fallback() {
let with_expression = mysql_list_indexes_sql("db", "users", true);
assert!(with_expression.contains("EXPRESSION, SEQ_IN_INDEX"));
let without_expression = mysql_list_indexes_sql("db", "users", false);
assert!(!without_expression.contains("EXPRESSION"));
assert!(without_expression.contains("ORDER BY INDEX_NAME, SEQ_IN_INDEX"));
}
#[test]
fn mysql_index_metadata_falls_back_only_for_unknown_expression_column() {
let unsupported = mysql_async::Error::Server(mysql_async::ServerError {
code: 1054,
message: "Unknown column 'EXPRESSION'".to_string(),
state: "42S22".to_string(),
});
let permission_denied = mysql_async::Error::Server(mysql_async::ServerError {
code: 1044,
message: "Access denied".to_string(),
state: "42000".to_string(),
});
assert!(mysql_statistics_expression_is_unsupported(&unsupported));
assert!(!mysql_statistics_expression_is_unsupported(&permission_denied));
}
#[test]
fn mysql_group_concat_not_supported_error_retries_without_session_variable() {
let error =
"MySQL connection failed: Server error: `ERROR 1235 (42000): SET of group_concat_max_len is not supported'";
assert_eq!(
mysql_group_concat_setup_fallback_mode(MySqlSetupMode::Standard, error),
Some(MySqlSetupMode::Compatible)
);
}
#[test]
fn mysql_gateway_forbidden_global_variables_error_retries_without_session_variable() {
let error = "MySQL connection failed: Server error: `ERROR 10192 (HY000): SET GLOBAL VARIABLES is forbidden'";
assert_eq!(
mysql_group_concat_setup_fallback_mode(MySqlSetupMode::Standard, error),
Some(MySqlSetupMode::Compatible)
);
}
#[test]
fn mysql_gateway_setup_retry_requires_exact_error_code_and_message() {
for error in [
"Server error: ERROR 10192 (HY000): operation is forbidden",
"Server error: ERROR 1227 (42000): SET GLOBAL VARIABLES is forbidden",
"Server error: ERROR 101920 (HY000): SET GLOBAL VARIABLES is forbidden",
] {
assert_eq!(mysql_group_concat_setup_fallback_mode(MySqlSetupMode::Standard, error), None);
}
}
#[test]
fn mysql_sphinxql_group_concat_boolean_error_retries_without_session_variable() {
let error = "MySQL connection failed: Server error: `ERROR 42000 (1064): sphinxql: only 0 and 1 could be used as boolean values near '1048576'`";
assert_eq!(
mysql_group_concat_setup_fallback_mode(MySqlSetupMode::Standard, error),
Some(MySqlSetupMode::Compatible)
);
}
#[test]
fn mysql_sphinxql_boolean_error_retry_stays_scoped_to_group_concat_setup() {
for error in [
"Server error: sphinxql: only 0 and 1 could be used as boolean values near '42'",
"Server error: only 0 and 1 could be used as boolean values near '1048576'",
] {
assert_eq!(mysql_group_concat_setup_fallback_mode(MySqlSetupMode::Standard, error), None);
}
}
#[test]
fn mysql_proxy_parse_tablename_1105_does_not_disable_group_concat() {
let error = "MySQL connection failed: Server error: `ERROR 07000 (1105): SQL操作失败 (operate fail ) :解析表名出错 ( parse tablename error ) '";
assert_eq!(mysql_group_concat_setup_fallback_mode(MySqlSetupMode::Standard, error), None);
}
#[test]
fn mysql_group_concat_setup_retry_is_narrow() {
assert_eq!(
mysql_group_concat_setup_fallback_mode(
MySqlSetupMode::Standard,
"MySQL connection failed: Server error: `ERROR HY000 (1193): Unknown system variable,stmt:SET @@sql_mode = ANSI'",
),
None
);
assert_eq!(
mysql_group_concat_setup_fallback_mode(
MySqlSetupMode::Standard,
"MySQL connection failed: Server error: `ERROR 07000 (1105): SQL操作失败 (operate fail)'",
),
None
);
}
#[test]
fn mysql_setup_queries_select_requested_database_before_session_init() {
let queries = mysql_setup_queries("mysql://root:secret@localhost:3306/app?charset=utf8mb4", &[]);
assert_eq!(queries, vec!["USE `app`", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]);
}
#[test]
fn mysql_setup_queries_skip_use_when_database_missing() {
let queries = mysql_setup_queries("mysql://root:secret@localhost:3306?charset=utf8mb4", &[]);
assert_eq!(queries, vec!["SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]);
}
#[test]
fn mysql_compatible_setup_queries_skip_group_concat_variable() {
let queries = mysql_setup_queries_with_mode(
"mysql://root:secret@localhost:9030/analytics?charset=utf8mb4",
&[],
MySqlSetupMode::Compatible,
);
assert_eq!(queries, vec!["USE `analytics`", "SET NAMES utf8mb4"]);
}
#[test]
fn mysql_compatible_setup_queries_keep_catalog_and_extra_queries() {
let extra = vec!["SET ob_query_timeout = 30000000".to_string()];
let queries = mysql_setup_queries_with_mode(
"mysql://root:secret@localhost:9030/clip?catalog=paimon_catalog",
&extra,
MySqlSetupMode::Compatible,
);
assert_eq!(
queries,
vec![
"USE `clip`",
"SET NAMES utf8mb4",
"SET catalog = `paimon_catalog`",
"SET ob_query_timeout = 30000000"
]
);
}
#[test]
fn mysql_setup_queries_decode_database_name_from_url() {
let queries = mysql_setup_queries("mysql://root:secret@localhost:3306/db%2Fname?charset=utf8mb4", &[]);
assert_eq!(queries, vec!["USE `db/name`", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]);
}
#[test]
fn mysql_setup_queries_preserve_database_identifier_whitespace() {
let queries = mysql_setup_queries("mysql://root:secret@localhost:3306/%20analytics%20?charset=utf8mb4", &[]);
assert_eq!(
queries,
vec!["USE ` analytics `", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]
);
}
#[test]
fn mysql_setup_queries_can_select_database_without_url_path() {
let queries = mysql_setup_queries_for_database(
"mysql://root:secret@localhost:3306?charset=utf8mb4",
Some("app`proxy"),
&[],
);
assert_eq!(
queries,
vec!["USE `app``proxy`", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]
);
}
#[test]
fn mysql_datetime_utc_values_display_without_rfc3339_offset() {
let value = NaiveDateTime::new(
NaiveDate::from_ymd_opt(2026, 5, 12).unwrap(),
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
);
assert_eq!(mysql_datetime_to_string(value), "2026-05-12 00:00:00");
}
#[test]
fn mysql_date_values_display_without_midnight_time() {
let date = NaiveDate::from_ymd_opt(2026, 6, 10).unwrap();
let datetime = date.and_hms_opt(0, 0, 0).unwrap();
assert_eq!(
mysql_temporal_value_to_json(ColumnType::MYSQL_TYPE_DATE, Some(datetime), Some(date), None),
Some(serde_json::json!("2026-06-10"))
);
}
#[test]
fn mysql_datetime_values_keep_time_component() {
let datetime = NaiveDate::from_ymd_opt(2026, 6, 10).unwrap().and_hms_opt(12, 34, 56).unwrap();
assert_eq!(
mysql_temporal_value_to_json(ColumnType::MYSQL_TYPE_DATETIME, Some(datetime), None, None),
Some(serde_json::json!("2026-06-10 12:34:56"))
);
}
#[tokio::test]
#[ignore = "requires remote MariaDB with ed25519 user"]
async fn test_ed25519_auth() {
let url = "mysql://edtest:test123@172.26.128.159:20026/testdb";
let pool = super::connect(url, std::time::Duration::from_secs(5)).await.expect("connect with ed25519");
let mut conn = pool.get_conn().await.expect("get connection");
conn.ping().await.expect("ping");
let _ = conn.disconnect().await;
let _ = pool.disconnect().await;
}
#[test]
fn parse_connect_timeout_extracts_underscore_form() {
let url = "mysql://host:3306/db?connect_timeout=30";
assert_eq!(crate::db::parse_connect_timeout(url), Duration::from_secs(30));
}
#[test]
fn parse_connect_timeout_extracts_camelcase_form() {
let url = "mysql://host:3306/db?connectTimeout=60";
assert_eq!(crate::db::parse_connect_timeout(url), Duration::from_secs(60));
}
#[test]
fn parse_connect_timeout_ignores_out_of_range() {
let default = connection_timeout();
let url = "mysql://host:3306/db?connect_timeout=999";
assert_eq!(crate::db::parse_connect_timeout(url), default);
let url2 = "mysql://host:3306/db?connect_timeout=0";
assert_eq!(crate::db::parse_connect_timeout(url2), default);
}
#[test]
fn parse_connect_timeout_returns_default_when_missing() {
let default = connection_timeout();
let url = "mysql://host:3306/db?ssl-mode=preferred&charset=utf8mb4";
assert_eq!(crate::db::parse_connect_timeout(url), default);
}
#[test]
fn parse_connect_timeout_returns_default_when_no_query() {
let default = connection_timeout();
let url = "mysql://host:3306/db";
assert_eq!(crate::db::parse_connect_timeout(url), default);
}
#[test]
fn mysql_async_url_translates_standard_required_ssl_mode() {
let url = "mysql://host:3306/db?ssl-mode=required&charset=utf8mb4";
assert_eq!(
mysql_async_url(url).as_ref(),
"mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false"
);
}
#[test]
fn mysql_async_url_translates_preferred_ssl_mode_to_tls_attempt() {
let url = "mysql://host:3306/db?ssl-mode=preferred&charset=utf8mb4";
assert_eq!(
mysql_async_url(url).as_ref(),
"mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false"
);
}
#[test]
fn mysql_async_url_translates_disabled_ssl_mode_even_when_param_count_matches() {
let url = "mysql://host:3306/db?ssl-mode=disabled";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db?require_ssl=false");
}
#[test]
fn mysql_async_url_translates_verify_identity_ssl_mode_even_when_param_count_matches() {
let url = "mysql://host:3306/db?sslmode=verify_identity";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db?require_ssl=true");
}
#[test]
fn mysql_async_url_strips_jdbc_params() {
let url = "mysql://host:3306/db?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db");
}
#[test]
fn mysql_async_url_keeps_valid_params_while_stripping_jdbc() {
let url = "mysql://host:3306/db?useUnicode=true&characterEncoding=utf8&require_ssl=true&charset=utf8mb4&autoReconnect=true&allowMultiQueries=true";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db?require_ssl=true");
}
#[test]
fn mysql_async_url_accepts_reported_doris_jdbc_params() {
let url = "mysql://host:9030/db?useLocalSessionState=true&rewriteBatchedStatements=true&prepStmtCacheSqlLimit=2048&prepStmtCacheSize=250&sessionVariables=query_timeout%3D60";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:9030/db");
}
#[test]
fn mysql_local_infile_paths_are_explicit_and_removed_from_driver_url() {
let url = "mysql://host:9030/db?localInfilePath=%2Ftmp%2Fone.csv&require_ssl=true&localinfilepath=C%3A%5Cdata%5Ctwo.csv";
assert_eq!(
mysql_local_infile_paths(url),
vec![PathBuf::from("/tmp/one.csv"), PathBuf::from(r"C:\data\two.csv")]
);
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:9030/db?require_ssl=true");
}
#[test]
fn mysql_local_infile_paths_ignore_empty_or_unrelated_values() {
let url = "mysql://host:9030/db?localInfilePath=&charset=utf8mb4&other=%2Ftmp%2Fignored.csv";
assert!(mysql_local_infile_paths(url).is_empty());
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:9030/db?other=%2Ftmp%2Fignored.csv");
}
#[test]
fn mysql_async_url_normalizes_cleartext_password_auth_alias() {
let url = "mysql://host:3306/db?allowCleartextPasswords=true&charset=utf8mb4";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db?enable_cleartext_plugin=true");
}
#[test]
fn mysql_async_url_deduplicates_cleartext_password_auth_params() {
let url = "mysql://host:3306/db?allowCleartextPasswords=true&enable_cleartext_plugin=true&require_ssl=true";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db?require_ssl=true&enable_cleartext_plugin=true");
}
#[test]
fn mysql_async_url_omits_disabled_cleartext_password_auth_params() {
let url = "mysql://host:3306/db?allowCleartextPasswords=false&enable_cleartext_plugin=&require_ssl=true";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db?require_ssl=true");
}
#[test]
fn mysql_async_url_strips_go_and_timezone_compat_params() {
let url = "mysql://host:3306/db?charset=utf8mb4&parseTime=True&loc=Local&connectionTimeZone=Asia%2FShanghai&forceConnectionTimeZoneToSession=true&require_ssl=true";
assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db?require_ssl=true");
}
#[test]
fn mysql_async_url_strips_database_path_when_catalog_present() {
// With a catalog configured, the database path must not reach mysql_async
// (it would be sent as the handshake schema and rejected before SET catalog).
assert_eq!(
mysql_async_url("mysql://root:secret@host:3306/clip?catalog=paimon_catalog").as_ref(),
"mysql://root:secret@host:3306"
);
assert_eq!(
mysql_async_url("mysql://host:3306/clip?catalog=paimon_catalog&require_ssl=true").as_ref(),
"mysql://host:3306?require_ssl=true"
);
}
#[test]
fn mysql_async_url_keeps_database_path_when_catalog_absent() {
assert_eq!(
mysql_async_url("mysql://host:3306/clip?require_ssl=true").as_ref(),
"mysql://host:3306/clip?require_ssl=true"
);
assert_eq!(mysql_async_url("mysql://host:3306/clip").as_ref(), "mysql://host:3306/clip");
}
#[test]
fn ssl_fallback_does_not_disable_required_tls() {
assert_eq!(ssl_fallback_url("mysql://host:3306/db?require_ssl=true&charset=utf8mb4"), None);
assert_eq!(ssl_fallback_url("mysql://host:3306/db?ssl-mode=verify_ca&charset=utf8mb4"), None);
}
#[test]
fn mysql_setup_queries_default_to_utf8mb4() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/db", &[]),
vec!["USE `db`", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]
);
}
#[test]
fn mysql_setup_queries_use_safe_custom_charset() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?ssl-mode=preferred&charset=gbk", &[]),
vec!["USE `db`", "SET NAMES gbk", "SET SESSION group_concat_max_len = 1048576"]
);
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?charset=utf8mb4;DROP TABLE users", &[]),
vec!["USE `db`", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]
);
}
#[test]
fn mysql_setup_queries_include_extra_setup_queries() {
let extra = vec!["SET ob_query_timeout = 30000000".to_string()];
assert_eq!(
mysql_setup_queries("mysql://host:3306/db", &extra),
vec![
"USE `db`",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576",
"SET ob_query_timeout = 30000000"
]
);
}
#[test]
fn mysql_setup_queries_apply_connector_j_session_variables() {
assert_eq!(
mysql_setup_queries(
"mysql://host:9030/db?sessionVariables=query_timeout%3D60%2Csql_mode%3D%27STRICT%2CTRADITIONAL%27%3B%40trace_id%3Dconcat%28%27a%2Cb%27%2C%27c%27%29",
&[],
),
vec![
"USE `db`",
"SET SESSION query_timeout=60,SESSION sql_mode='STRICT,TRADITIONAL',@trace_id=concat('a,b','c')",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576",
]
);
}
#[test]
fn mysql_setup_queries_ignore_empty_session_variables() {
assert_eq!(
mysql_setup_queries("mysql://host:9030/db?sessionVariables=%20%2C%20%3B%20", &[]),
vec!["USE `db`", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]
);
}
#[test]
fn mysql_setup_queries_apply_explicit_time_zone() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?time_zone=%2B08%3A00&charset=utf8mb4", &[]),
vec![
"USE `db`",
"SET time_zone = '+08:00'",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576"
]
);
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?time-zone=Asia%2FShanghai", &[]),
vec![
"USE `db`",
"SET time_zone = 'Asia/Shanghai'",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576"
]
);
}
#[test]
fn mysql_setup_queries_apply_jdbc_time_zone_aliases() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?serverTimezone=GMT%2B8", &[]),
vec![
"USE `db`",
"SET time_zone = '+08:00'",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576"
]
);
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?connectionTimeZone=UTC", &[]),
vec![
"USE `db`",
"SET time_zone = '+00:00'",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576"
]
);
}
#[test]
fn mysql_setup_queries_apply_go_loc_when_no_explicit_time_zone_exists() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?loc=Asia%2FShanghai", &[]),
vec![
"USE `db`",
"SET time_zone = 'Asia/Shanghai'",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576"
]
);
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?time_zone=%2B08%3A00&loc=UTC", &[]),
vec![
"USE `db`",
"SET time_zone = '+08:00'",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576"
]
);
}
#[test]
fn mysql_setup_queries_ignore_unsafe_time_zone_values() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?time_zone=%2B08%3A00%27%3BDROP%20TABLE%20users", &[]),
vec!["USE `db`", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]
);
}
#[test]
fn mysql_setup_queries_switch_catalog_when_present() {
// `SET catalog` is pushed last so mysql_async's back-to-front setup
// execution (Vec::pop) runs it before `USE <database>`.
assert_eq!(
mysql_setup_queries("mysql://host:3306/clip?catalog=paimon_catalog", &[]),
vec![
"USE `clip`",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576",
"SET catalog = `paimon_catalog`"
]
);
}
#[test]
fn mysql_setup_queries_switch_catalog_without_database() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/?catalog=paimon_catalog", &[]),
vec!["SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576", "SET catalog = `paimon_catalog`"]
);
}
#[test]
fn mysql_setup_queries_decodes_catalog_parameter() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?catalog=my%5Fcatalog", &[]),
vec![
"USE `db`",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576",
"SET catalog = `my_catalog`"
]
);
}
#[test]
fn mysql_setup_queries_omits_catalog_when_absent() {
assert_eq!(
mysql_setup_queries("mysql://host:3306/db?charset=utf8mb4", &[]),
vec!["USE `db`", "SET NAMES utf8mb4", "SET SESSION group_concat_max_len = 1048576"]
);
}
}