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

6727 lines
257 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 sqlparser::ast::Statement;
use sqlparser::dialect::MySqlDialect;
use sqlparser::parser::Parser;
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, SpatialColumnBuilder, TableInfo, TriggerInfo,
};
use super::file_validator::validate_file_path;
pub type MySqlPool = mysql_async::Pool;
const MYSQL_TCP_KEEPALIVE_MS: u32 = 30_000;
const MYSQL_SQL_PACKET_MARGIN_MAX_BYTES: usize = 64 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MySqlCatalogDialect {
Doris,
StarRocks,
}
pub(crate) fn mysql_catalog_dialect(
db_type: DatabaseType,
driver_profile: Option<&str>,
) -> Option<MySqlCatalogDialect> {
match db_type {
DatabaseType::Doris => Some(MySqlCatalogDialect::Doris),
DatabaseType::StarRocks => Some(MySqlCatalogDialect::StarRocks),
_ => match driver_profile.map(str::to_ascii_lowercase).as_deref() {
Some("doris" | "selectdb") => Some(MySqlCatalogDialect::Doris),
Some("starrocks") => Some(MySqlCatalogDialect::StarRocks),
_ => None,
},
}
}
#[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> {
// MySQL reports nullable metadata such as TABLE_COLLATION as NULL for views.
// Reading it as String makes mysql_async panic during row conversion.
match conn.query_first::<Option<String>, _>(sql).await {
Ok(Some(value)) => value.and_then(nonblank),
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) {
decode_mysql_geometry(&bytes)
.map(|geometry| geometry.wkt)
.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 decode_mysql_geometry(bytes: &[u8]) -> Option<super::wkb::DecodedGeometry> {
if bytes.len() >= 5 && matches!(bytes[4], 0 | 1) {
let prefix: [u8; 4] = bytes[..4].try_into().ok()?;
if let Some(mut geometry) = super::wkb::decode_wkb_geometry(&bytes[4..]) {
if geometry.srid.is_none() {
let srid = u32::from_le_bytes(prefix);
geometry.srid = (srid != 0).then_some(srid);
}
return Some(geometry);
}
}
super::wkb::decode_wkb_geometry(bytes)
}
fn mysql_spatial_column_builder(columns: &[mysql_async::Column]) -> SpatialColumnBuilder {
SpatialColumnBuilder::new(
columns
.iter()
.enumerate()
.filter_map(|(index, column)| (column.column_type() == ColumnType::MYSQL_TYPE_GEOMETRY).then_some(index)),
)
}
fn mysql_row_to_json_with_srids(
row: &mysql_async::Row,
spatial_columns: &mut SpatialColumnBuilder,
) -> (Vec<serde_json::Value>, Vec<Option<u32>>) {
let mut srids = vec![None; row.len()];
let values = (0..row.len())
.map(|idx| {
let is_geometry = row
.columns_ref()
.get(idx)
.is_some_and(|column| column.column_type() == ColumnType::MYSQL_TYPE_GEOMETRY);
if !is_geometry {
return mysql_value_to_json(row, idx);
}
let Some(bytes) = row_get::<Vec<u8>, _>(row, idx) else {
spatial_columns.observe(idx, None);
return serde_json::Value::Null;
};
match decode_mysql_geometry(&bytes) {
Some(geometry) => {
spatial_columns.observe(idx, geometry.srid);
srids[idx] = geometry.srid;
serde_json::Value::String(geometry.wkt)
}
None => {
spatial_columns.observe(idx, None);
super::binary_value_to_json(&bytes)
}
}
})
.collect();
(values, srids)
}
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 result = connect_pool_attempt_with_keepalive(
url,
ca_cert_path,
timeout,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
setup_mode,
eof_mode,
MySqlTcpKeepaliveMode::Enabled,
)
.await;
if result.as_ref().err().is_some_and(|error| mysql_error_should_retry_without_tcp_keepalive(error)) {
log::info!("MySQL connection returned EBADF; retrying with TCP keepalive disabled");
return connect_pool_attempt_with_keepalive(
url,
ca_cert_path,
timeout,
max_connections,
idle_timeout_secs,
setup_database,
extra_setup_queries,
setup_mode,
eof_mode,
MySqlTcpKeepaliveMode::Disabled,
)
.await;
}
result
}
#[allow(clippy::too_many_arguments)]
async fn connect_pool_attempt_with_keepalive(
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,
tcp_keepalive_mode: MySqlTcpKeepaliveMode,
) -> 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,
tcp_keepalive_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,
tcp_keepalive_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,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MySqlTcpKeepaliveMode {
Enabled,
Disabled,
}
impl MySqlEofMode {
fn deprecate_eof(self) -> bool {
self == Self::Deprecate
}
}
impl MySqlTcpKeepaliveMode {
fn duration(self) -> Option<Duration> {
match self {
Self::Enabled => Some(Duration::from_millis(u64::from(MYSQL_TCP_KEEPALIVE_MS))),
Self::Disabled => None,
}
}
}
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,
}
}
}
#[allow(clippy::too_many_arguments)]
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,
tcp_keepalive_mode: MySqlTcpKeepaliveMode,
) -> 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,
tcp_keepalive_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,
tcp_keepalive_mode: MySqlTcpKeepaliveMode,
) -> 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(tcp_keepalive_mode.duration())
.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 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);
}
queries.extend(extra_setup_queries.iter().cloned());
queries
}
fn catalog_switch_query(dialect: MySqlCatalogDialect, catalog: &str) -> String {
let catalog = quote_identifier(catalog);
match dialect {
MySqlCatalogDialect::Doris => format!("SWITCH {catalog}"),
MySqlCatalogDialect::StarRocks => format!("SET CATALOG {catalog}"),
}
}
pub(crate) fn catalog_setup_query_for_url(dialect: MySqlCatalogDialect, url: &str) -> Option<String> {
mysql_connection_catalog(url).map(|catalog| catalog_switch_query(dialect, &catalog))
}
pub(crate) fn catalog_database_context_queries(
dialect: Option<MySqlCatalogDialect>,
catalog: Option<&str>,
database: &str,
) -> Result<Vec<String>, String> {
let Some(catalog) = catalog.filter(|value| !value.trim().is_empty()) else {
return Ok(Vec::new());
};
let dialect = dialect.ok_or("Catalog selection is only supported for Doris and StarRocks")?;
let mut queries = Vec::with_capacity(2);
queries.push(catalog_switch_query(dialect, catalog));
if !database.trim().is_empty() {
queries.push(format!("USE {}", quote_identifier(database)));
}
Ok(queries)
}
pub(crate) async fn apply_catalog_database_context(
conn: &mut mysql_async::Conn,
dialect: Option<MySqlCatalogDialect>,
catalog: Option<&str>,
database: &str,
) -> Result<(), String> {
for query in catalog_database_context_queries(dialect, catalog, database)? {
conn.query_drop(&query).await.map_err(|error| format!("Failed to select query catalog/database: {error}"))?;
}
Ok(())
}
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 emits the database-specific catalog switch 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_without_tcp_keepalive(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("bad file descriptor") && error.contains("os error 9")
}
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 the catalog switch 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
}
const SHOW_DATABASES_SQL: &str = "SHOW DATABASES";
const INFORMATION_SCHEMA_DATABASES_SQL: &str =
"SELECT SCHEMA_NAME FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME";
const DATABASE_LIST_QUERY_PLAN: [(&str, bool); 2] =
[(SHOW_DATABASES_SQL, true), (INFORMATION_SCHEMA_DATABASES_SQL, false)];
pub async fn list_databases(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, String> {
let [(primary_sql, primary_catalogless), (fallback_sql, fallback_catalogless)] = DATABASE_LIST_QUERY_PLAN;
match list_databases_with_query(pool, primary_sql, primary_catalogless).await {
Ok(databases) => Ok(databases),
Err(err) => {
log::debug!("Falling back to information_schema.SCHEMATA after SHOW DATABASES failed: {err}");
list_databases_with_query(pool, fallback_sql, fallback_catalogless).await
}
}
}
async fn list_databases_with_query(
pool: &MySqlPool,
sql: &str,
include_catalogless_when_blank: bool,
) -> Result<Vec<DatabaseInfo>, String> {
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(database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), include_catalogless_when_blank))
}
pub async fn list_databases_show(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, String> {
list_databases_with_query(pool, SHOW_DATABASES_SQL, true).await
}
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
}
fn normalize_mysql_table_type(table_type: &str) -> String {
let trimmed = table_type.trim();
if trimmed.is_empty() {
return "TABLE".to_string();
}
if trimmed.eq_ignore_ascii_case("VIEW") || trimmed.eq_ignore_ascii_case("SYSTEM VIEW") {
return "VIEW".to_string();
}
trimmed.to_string()
}
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: normalize_mysql_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 NOT IN ('VIEW', 'SYSTEM VIEW')"),
(false, true) => sql.push_str(" AND TABLE_TYPE IN ('VIEW', 'SYSTEM 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 = normalize_mysql_table_type(&get_str(row, 1));
Some(TableInfo { name, 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 wants_trigger_objects(object_types: Option<&[String]>) -> bool {
requested_object_type(object_types, "TRIGGER")
}
fn wants_event_objects(object_types: Option<&[String]>) -> bool {
requested_object_type(object_types, "EVENT")
}
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 NOT IN ('VIEW', 'SYSTEM VIEW')",
(false, true) => " AND TABLE_TYPE IN ('VIEW', 'SYSTEM VIEW')",
_ => "",
};
format!(
"SELECT TABLE_NAME AS object_name, \
CASE WHEN TABLE_TYPE IN ('VIEW', 'SYSTEM 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 IN ('VIEW', 'SYSTEM 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"),
}
}
fn list_triggers_objects_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, \
5 AS sort_order \
FROM information_schema.TRIGGERS \
WHERE TRIGGER_SCHEMA = {} \
ORDER BY object_name",
quote_value(database)
)
}
fn list_events_objects_sql(database: &str) -> String {
format!(
"SELECT EVENT_NAME AS object_name, 'EVENT' AS object_type, NULL AS object_comment, \
CREATED AS created_at, LAST_ALTERED AS updated_at, \
EVENT_SCHEMA AS parent_schema, NULL AS parent_name, \
6 AS sort_order \
FROM information_schema.EVENTS \
WHERE EVENT_SCHEMA = {} \
ORDER BY object_name",
quote_value(database)
)
}
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);
}
}
}
if wants_trigger_objects(object_types) {
let triggers_sql = list_triggers_objects_sql(database);
match conn.query_iter(&triggers_sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(trigger_rows) => {
objects.extend(trigger_rows.iter().map(|row| row_to_object(row, database)));
}
Err(e) => {
log::warn!("Skipping triggers for database `{}` in object browser: {}", database, e);
}
},
Err(e) => {
log::warn!("Skipping triggers for database `{}` in object browser: {}", database, e);
}
}
}
if wants_event_objects(object_types) {
let events_sql = list_events_objects_sql(database);
match conn.query_iter(&events_sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(event_rows) => {
objects.extend(event_rows.iter().map(|row| row_to_object(row, database)));
}
Err(e) => {
log::warn!("Skipping events for database `{}` in object browser: {}", database, e);
}
},
Err(e) => {
log::warn!("Skipping events 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 {
// Query only information_schema.COLUMNS and fetch TABLE_COLLATION separately via
// `table_collation_sql`. A LEFT JOIN onto information_schema.TABLES triggers a
// catastrophic plan on MySQL 5.7 (observed ~8s vs ~1ms without the join), because 5.7's
// TABLES metadata is materialized per-query with poor predicate pushdown. The separate
// lookup matches the `get_columns_show` fallback path and keeps results identical.
format!(
"SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, \
COLUMN_COMMENT, COLUMN_KEY, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, \
CHARACTER_SET_NAME, COLLATION_NAME \
FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
ORDER BY 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())?;
// When database is empty the COLUMNS query returns no rows, so the
// function falls through to get_columns_show and this code path is
// never reached. Skip the collation lookup to avoid a pointless query.
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, "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"),
is_unique: column_key.eq_ignore_ascii_case("UNI"),
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"),
is_unique: key.eq_ignore_ascii_case("UNI"),
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![],
spatial_columns: vec![],
spatial_values: 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();
let mut spatial_columns = mysql_spatial_column_builder(result.columns_ref());
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 mut spatial_values = Vec::new();
let result_rows = rows
.iter()
.take(row_limit)
.map(|row| {
let (values, srids) = mysql_row_to_json_with_srids(row, &mut spatial_columns);
spatial_values.push(srids);
values
})
.collect();
return Ok(QueryResult {
columns,
column_types,
column_sortables: vec![],
spatial_columns: spatial_columns.finish(),
spatial_values,
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 spatial_values: Vec<Vec<Option<u32>>> = Vec::new();
let mut truncated = false;
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())?;
if result_rows.len() >= row_limit {
truncated = true;
break;
}
let (values, srids) = mysql_row_to_json_with_srids(&row, &mut spatial_columns);
result_rows.push(values);
spatial_values.push(srids);
}
Ok(QueryResult {
columns,
column_types,
column_sortables: vec![],
spatial_columns: spatial_columns.finish(),
spatial_values,
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 execute_result_sets_with_text_protocol_on_conn(
conn: &mut mysql_async::Conn,
sql: &str,
row_limit: usize,
max_rows: Option<usize>,
start: Instant,
) -> Result<Vec<QueryResult>, String> {
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
let mut results = Vec::new();
while advance_to_result_set_with_columns(&mut result).await? {
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 spatial_columns = mysql_spatial_column_builder(result.columns_ref());
let mut spatial_values = Vec::new();
let mut truncated = false;
let rows = if should_collect_text_result_set(sql, row_limit, max_rows) {
let rows: Vec<mysql_async::Row> = result.collect().await.map_err(|e| e.to_string())?;
truncated = rows.len() > row_limit;
rows.iter()
.take(row_limit)
.map(|row| {
let (values, srids) = mysql_row_to_json_with_srids(row, &mut spatial_columns);
spatial_values.push(srids);
values
})
.collect()
} else {
let mut rows = 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())?;
if rows.len() < row_limit {
let (values, srids) = mysql_row_to_json_with_srids(&row, &mut spatial_columns);
rows.push(values);
spatial_values.push(srids);
} else {
truncated = true;
}
}
rows
};
results.push(QueryResult {
columns,
column_types,
column_sortables: vec![],
spatial_columns: spatial_columns.finish(),
spatial_values,
rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
});
}
if results.is_empty() {
results.push(QueryResult {
columns: vec![],
column_types: Vec::new(),
column_sortables: vec![],
spatial_columns: vec![],
spatial_values: 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,
});
}
Ok(results)
}
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 spatial_columns = mysql_spatial_column_builder(result.columns_ref());
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut spatial_values: Vec<Vec<Option<u32>>> = Vec::new();
let mut truncated = false;
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())?;
if result_rows.len() >= row_limit {
truncated = true;
break;
}
let (values, srids) = mysql_row_to_json_with_srids(&row, &mut spatial_columns);
result_rows.push(values);
spatial_values.push(srids);
}
Ok(QueryResult {
columns,
column_types,
column_sortables: vec![],
spatial_columns: spatial_columns.finish(),
spatial_values,
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 max_allowed_packet(pool: &MySqlPool) -> Result<u64, String> {
let mut conn = get_conn_with_health_check(pool).await?;
conn.query_first::<u64, _>("SELECT @@max_allowed_packet")
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "MySQL did not return @@max_allowed_packet".to_string())
}
pub(crate) fn mysql_sql_statement_hard_limit(max_allowed_packet: u64) -> Option<usize> {
let packet_bytes = usize::try_from(max_allowed_packet).ok()?;
if packet_bytes == 0 {
return None;
}
let margin = (packet_bytes / 10).clamp(1024, MYSQL_SQL_PACKET_MARGIN_MAX_BYTES).min(packet_bytes / 2);
packet_bytes.checked_sub(margin).filter(|limit| *limit > 0)
}
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![],
spatial_columns: vec![],
spatial_values: vec![],
rows: vec![],
affected_rows,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
})
}
}
pub async fn execute_query_results_on_conn_with_max_rows(
conn: &mut mysql_async::Conn,
sql: &str,
bare: bool,
max_rows: Option<usize>,
dialect: MySqlQueryDialect,
) -> Result<Vec<QueryResult>, String> {
if is_result_set_query(sql, dialect) && (bare || prefers_text_protocol_query(sql, dialect)) {
let start = Instant::now();
execute_result_sets_with_text_protocol_on_conn(conn, sql, query_result_row_limit(max_rows), max_rows, start)
.await
} else {
execute_query_on_conn_with_max_rows(conn, sql, bare, max_rows, dialect).await.map(|result| vec![result])
}
}
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)
}
pub(crate) 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,
) || mysql_statement_returns_rows(sql)
|| dialect.supports_admin_show_results && is_admin_show_query(sql)
}
/// MariaDB 10.5+ returns a result set for INSERT/DELETE/REPLACE ... RETURNING.
/// Route it through the existing query path; MySQL servers that do not support
/// the syntax still return their normal SQL syntax error.
fn mysql_statement_returns_rows(sql: &str) -> bool {
let Ok(statements) = Parser::parse_sql(&MySqlDialect {}, sql) else {
return false;
};
let [statement] = statements.as_slice() else {
return false;
};
match statement {
Statement::Insert(insert) => insert.returning.is_some(),
Statement::Delete(delete) => delete.returning.is_some(),
_ => false,
}
}
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())
}
// ---------------------------------------------------------------------------
// Doris / StarRocks multi-catalog support.
//
// These engines expose external catalogs (iceberg, hive, jdbc, ...) alongside
// the native `internal` catalog via `SHOW CATALOGS`. The functions below address
// objects in a specific catalog using 3-part qualified names
// (`<catalog>.<database>.<table>`), which the engines accept directly without
// needing to `SWITCH` the session catalog.
// ---------------------------------------------------------------------------
/// Build a 2-part qualified identifier `` `<catalog>`.`<database>` ``.
fn doris_catalog_database_ref(catalog: &str, database: &str) -> String {
format!("{}.{}", quote_identifier(catalog), quote_identifier(database))
}
/// Build a 3-part qualified identifier `` `<catalog>`.`<database>`.`<table>` ``.
fn doris_catalog_table_ref(catalog: &str, database: &str, table: &str) -> String {
format!("{}.{}.{}", quote_identifier(catalog), quote_identifier(database), quote_identifier(table))
}
/// `SHOW CATALOGS` → list of catalogs visible to the current user.
///
/// Column layouts differ between engines: Doris exposes `CatalogName` (with
/// `CatalogId`/`IsCurrent`/`CreateTime`/`LastUpdateTime`), while StarRocks
/// exposes `Catalog` (only `Type`/`Comment`, no `IsCurrent`). The name is read
/// from either column; missing trailing columns degrade gracefully to
/// empty/None. The built-in catalog is named `internal` in Doris and
/// `default_catalog` in StarRocks (both with `Type=internal`); detection is
/// type-based (see `CatalogInfo::is_internal`), not name-based.
pub async fn list_doris_catalogs(pool: &MySqlPool) -> Result<Vec<crate::db::CatalogInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter("SHOW CATALOGS").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 catalogs: Vec<crate::db::CatalogInfo> = rows
.iter()
.filter_map(|row| {
// Doris column is `CatalogName`; StarRocks column is `Catalog`.
let name = first_nonempty_str_by_name(row, &["CatalogName", "Catalog"]).trim().to_string();
if name.is_empty() {
return None;
}
let catalog_type = get_str_by_name(row, "Type").trim().to_string();
let is_current = {
let value = get_str_by_name(row, "IsCurrent").trim().to_ascii_lowercase();
!value.is_empty() && value != "no" && value != "false" && value != "0"
};
let comment = get_opt_str(row, "Comment").map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
Some(crate::db::CatalogInfo { name, catalog_type, is_current, comment })
})
.collect();
Ok(normalize_doris_catalogs(catalogs))
}
/// Sort with the built-in catalog first, then the rest alphabetically by name.
/// The built-in catalog is identified by `CatalogInfo::is_internal` (type-based)
/// rather than by name, so StarRocks `default_catalog` sorts first just like
/// Doris `internal`. No synthetic catalog is injected: `SHOW CATALOGS` always
/// lists the built-in catalog on both engines, and a single-catalog result is
/// handled by the flat-sidebar fallback in the caller.
fn normalize_doris_catalogs(mut catalogs: Vec<crate::db::CatalogInfo>) -> Vec<crate::db::CatalogInfo> {
catalogs.sort_by(|a, b| match (a.is_internal(), b.is_internal()) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
catalogs
}
/// `SHOW DATABASES FROM <catalog>` → databases in the given catalog.
pub async fn list_databases_show_from(pool: &MySqlPool, catalog: &str) -> Result<Vec<DatabaseInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let sql = format!("SHOW DATABASES FROM {}", quote_identifier(catalog));
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(database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), false))
}
/// `SHOW TABLES FROM <catalog>.<database>` → tables in an external catalog.
///
/// External catalogs do not support `SHOW TABLE STATUS`, so comments/status are
/// not fetched (the caller only needs names + types for browsing).
pub async fn list_tables_show_from(pool: &MySqlPool, catalog: &str, database: &str) -> Result<Vec<TableInfo>, String> {
let sql = format!("SHOW TABLES FROM {}", doris_catalog_database_ref(catalog, 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())?;
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;
}
// SHOW FULL TABLES exposes a type column; plain SHOW TABLES does not.
let table_type = get_str(row, 1);
Some(TableInfo {
name,
table_type: if table_type.trim().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)
}
/// `SHOW COLUMNS FROM <catalog>.<database>.<table>` → columns of an external
/// catalog table. Falls back to `DESCRIBE` if `SHOW COLUMNS` is rejected.
pub async fn get_columns_show_from(
pool: &MySqlPool,
catalog: &str,
database: &str,
table: &str,
) -> Result<Vec<ColumnInfo>, String> {
let qualified = doris_catalog_table_ref(catalog, database, table);
let full_sql = format!("SHOW FULL COLUMNS FROM {qualified}");
let plain_sql = format!("SHOW COLUMNS FROM {qualified}");
let describe_sql = format!("DESCRIBE {qualified}");
let mut conn = get_conn_with_health_check(pool).await?;
let rows: Vec<mysql_async::Row> = match conn.query_iter(&full_sql).await {
Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?,
Err(_) => match conn.query_iter(&plain_sql).await {
Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?,
Err(_) => {
let result = conn.query_iter(&describe_sql).await.map_err(|e| e.to_string())?;
result.collect_and_drop().await.map_err(|e| e.to_string())?
}
},
};
Ok(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"),
is_unique: key.eq_ignore_ascii_case("UNI"),
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())
}
/// `SHOW CREATE TABLE <catalog>.<database>.<table>` → DDL for an external
/// catalog table.
pub async fn show_create_table_ddl_from(
pool: &MySqlPool,
catalog: &str,
database: &str,
table: &str,
) -> Result<String, String> {
let sql = format!("SHOW CREATE TABLE {}", doris_catalog_table_ref(catalog, 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(|b| String::from_utf8_lossy(&b).to_string())
})
.ok_or_else(|| "Failed to read DDL".to_string())
}
/// Best-effort index listing for an external catalog table. External catalogs
/// generally do not expose MySQL-style index metadata via `information_schema`
/// (that view is scoped to the internal catalog), so indexes are derived from
/// `SHOW CREATE TABLE` parsing. Returns empty on failure (graceful degradation
/// — indexes are informational for external tables).
pub async fn list_doris_catalog_indexes(
pool: &MySqlPool,
catalog: &str,
database: &str,
table: &str,
) -> Result<Vec<IndexInfo>, String> {
let ddl = show_create_table_ddl_from(pool, catalog, database, table).await?;
Ok(doris_indexes_from_create_table_ddl(&ddl))
}
fn doris_indexes_from_create_table_ddl(ddl: &str) -> Vec<IndexInfo> {
let mut indexes = Vec::new();
for raw_line in ddl.lines() {
let line = trim_ddl_definition_line(raw_line);
if line.is_empty() {
continue;
}
let upper = line.to_ascii_uppercase();
if upper.starts_with("PRIMARY KEY") {
if let Some(index) = doris_table_key_index("PRIMARY", line, true, true, "PRIMARY KEY") {
indexes.push(index);
}
} else if upper.starts_with("UNIQUE KEY") {
if let Some(index) = doris_table_key_index("UNIQUE KEY", line, true, false, "UNIQUE KEY") {
indexes.push(index);
}
} else if upper.starts_with("INDEX ") {
if let Some(index) = doris_secondary_index(line) {
indexes.push(index);
}
}
}
indexes
}
fn trim_ddl_definition_line(line: &str) -> &str {
let mut trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(',') {
trimmed = rest.trim_start();
}
while let Some(rest) = trimmed.strip_suffix(',') {
trimmed = rest.trim_end();
}
trimmed
}
fn doris_table_key_index(
name: &str,
line: &str,
is_unique: bool,
is_primary: bool,
index_type: &str,
) -> Option<IndexInfo> {
let columns = parse_mysql_index_columns(first_parenthesized_content(line)?);
if columns.is_empty() {
return None;
}
Some(IndexInfo {
name: name.to_string(),
columns,
is_unique,
is_primary,
filter: None,
index_type: Some(index_type.to_string()),
included_columns: None,
comment: None,
})
}
fn doris_secondary_index(line: &str) -> Option<IndexInfo> {
let (_, rest) = split_keyword_prefix(line, "INDEX")?;
let (name, after_name) = read_mysql_identifier(rest.trim_start())?;
let columns = parse_mysql_index_columns(first_parenthesized_content(after_name)?);
if columns.is_empty() {
return None;
}
Some(IndexInfo {
name,
columns,
is_unique: false,
is_primary: false,
filter: None,
index_type: mysql_keyword_argument(after_name, "USING").or_else(|| Some("INDEX".to_string())),
included_columns: None,
comment: mysql_quoted_string_argument(after_name, "COMMENT"),
})
}
fn split_keyword_prefix<'a>(line: &'a str, keyword: &str) -> Option<(&'a str, &'a str)> {
if line.len() < keyword.len() || !line[..keyword.len()].eq_ignore_ascii_case(keyword) {
return None;
}
let rest = &line[keyword.len()..];
if !rest.is_empty() && is_mysql_identifier_byte(rest.as_bytes()[0]) {
return None;
}
Some((&line[..keyword.len()], rest))
}
fn read_mysql_identifier(input: &str) -> Option<(String, &str)> {
let input = input.trim_start();
if input.is_empty() {
return None;
}
let bytes = input.as_bytes();
if bytes[0] == b'`' {
let mut i = 1;
let mut value = String::new();
while i < bytes.len() {
if bytes[i] == b'`' {
if i + 1 < bytes.len() && bytes[i + 1] == b'`' {
value.push('`');
i += 2;
continue;
}
return Some((value, &input[i + 1..]));
}
let ch = input[i..].chars().next()?;
value.push(ch);
i += ch.len_utf8();
}
return None;
}
let end = input.find(|ch: char| ch.is_whitespace() || matches!(ch, '(' | ')' | ',')).unwrap_or(input.len());
if end == 0 {
return None;
}
Some((input[..end].to_string(), &input[end..]))
}
fn first_parenthesized_content(input: &str) -> Option<&str> {
let bytes = input.as_bytes();
let mut depth = 0usize;
let mut start = None;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' | b'"' | b'`' => {
i = skip_mysql_quoted(input, i, bytes[i]);
continue;
}
b'(' => {
if depth == 0 {
start = Some(i + 1);
}
depth += 1;
}
b')' if depth > 0 => {
depth -= 1;
if depth == 0 {
return start.map(|start| &input[start..i]);
}
}
_ => {}
}
i += 1;
}
None
}
fn split_top_level_csv(input: &str) -> Vec<&str> {
let bytes = input.as_bytes();
let mut parts = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' | b'"' | b'`' => {
i = skip_mysql_quoted(input, i, bytes[i]);
continue;
}
b'(' => depth += 1,
b')' if depth > 0 => depth -= 1,
b',' if depth == 0 => {
parts.push(input[start..i].trim());
start = i + 1;
}
_ => {}
}
i += 1;
}
parts.push(input[start..].trim());
parts
}
fn parse_mysql_index_columns(input: &str) -> Vec<String> {
split_top_level_csv(input)
.into_iter()
.filter_map(|part| read_mysql_identifier(part).map(|(column, _)| column))
.filter(|column| !column.is_empty())
.collect()
}
fn mysql_keyword_argument(input: &str, keyword: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' | b'"' | b'`' => {
i = skip_mysql_quoted(input, i, bytes[i]);
continue;
}
_ if mysql_keyword_at(input, i, keyword) => {
return read_mysql_identifier(&input[i + keyword.len()..]).map(|(value, _)| value);
}
_ => i += 1,
}
}
None
}
fn mysql_quoted_string_argument(input: &str, keyword: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' | b'"' | b'`' => {
i = skip_mysql_quoted(input, i, bytes[i]);
continue;
}
_ if mysql_keyword_at(input, i, keyword) => {
let rest = input[i + keyword.len()..].trim_start();
if rest.as_bytes().first().copied() != Some(b'\'') {
return None;
}
let end = skip_mysql_quoted(rest, 0, b'\'');
if end <= 1 || end > rest.len() {
return None;
}
return Some(rest[1..end - 1].replace("\\'", "'").replace("''", "'"));
}
_ => i += 1,
}
}
None
}
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::*;
#[test]
fn mysql_sql_statement_limit_reserves_packet_headroom() {
let packet_bytes = 64 * 1024 * 1024;
let hard_limit = mysql_sql_statement_hard_limit(packet_bytes).unwrap();
assert!(hard_limit < packet_bytes as usize);
assert!(hard_limit >= packet_bytes as usize * 9 / 10);
assert_eq!(mysql_sql_statement_hard_limit(0), None);
assert_eq!(mysql_sql_statement_hard_limit(4096), Some(3072));
}
use crate::db::connection_timeout;
use mysql_async::consts::ColumnFlags;
#[test]
fn catalog_database_context_uses_database_specific_syntax_before_database() {
assert_eq!(
catalog_database_context_queries(Some(MySqlCatalogDialect::Doris), Some("paimon`catalog"), "bi").unwrap(),
vec!["SWITCH `paimon``catalog`", "USE `bi`"]
);
assert_eq!(
catalog_database_context_queries(Some(MySqlCatalogDialect::StarRocks), Some("paimon`catalog"), "bi")
.unwrap(),
vec!["SET CATALOG `paimon``catalog`", "USE `bi`"]
);
assert_eq!(
catalog_database_context_queries(Some(MySqlCatalogDialect::Doris), None, "").unwrap(),
Vec::<String>::new()
);
assert!(catalog_database_context_queries(None, Some("paimon_catalog"), "bi").is_err());
}
#[test]
fn catalog_dialect_supports_native_and_profile_connections() {
assert_eq!(mysql_catalog_dialect(DatabaseType::Doris, None), Some(MySqlCatalogDialect::Doris));
assert_eq!(mysql_catalog_dialect(DatabaseType::StarRocks, None), Some(MySqlCatalogDialect::StarRocks));
assert_eq!(mysql_catalog_dialect(DatabaseType::Mysql, Some("selectdb")), Some(MySqlCatalogDialect::Doris));
assert_eq!(mysql_catalog_dialect(DatabaseType::Mysql, Some("STARROCKS")), Some(MySqlCatalogDialect::StarRocks));
assert_eq!(mysql_catalog_dialect(DatabaseType::Mysql, None), None);
}
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 mysql_geometry_decoder_retains_srid_prefix() {
let mut raw = 3857_u32.to_le_bytes().to_vec();
raw.extend_from_slice(&[
0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x40,
]);
let decoded = decode_mysql_geometry(&raw).unwrap();
assert_eq!(decoded.wkt, "POINT(1 2)");
assert_eq!(decoded.srid, Some(3857));
}
#[test]
fn mysql_geometry_decoder_accepts_unprefixed_wkb() {
let raw = [
0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x40,
];
let decoded = decode_mysql_geometry(&raw).unwrap();
assert_eq!(decoded.wkt, "POINT(1 2)");
assert_eq!(decoded.srid, None);
}
#[test]
fn mysql_geometry_srid_zero_is_unknown() {
let mut raw = 0_u32.to_le_bytes().to_vec();
raw.extend_from_slice(&[
0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x40,
]);
assert_eq!(decode_mysql_geometry(&raw).unwrap().srid, 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 mariadb_returning_dml_is_treated_as_a_result_set() {
let dialect = MySqlQueryDialect::default();
assert!(is_result_set_query("INSERT INTO users (id) VALUES (1) RETURNING id", dialect));
assert!(is_result_set_query("DELETE FROM users WHERE id = 1 RETURNING id", dialect));
assert!(!is_result_set_query("UPDATE users SET name = 'Ada'", dialect));
}
#[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 NOT IN ('VIEW', 'SYSTEM VIEW')"));
assert!(table_sql.find("TABLE_TYPE NOT IN ('VIEW', 'SYSTEM 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 IN ('VIEW', 'SYSTEM VIEW')"));
}
#[test]
fn mysql_system_views_are_normalized_as_views() {
assert_eq!(normalize_mysql_table_type("SYSTEM VIEW"), "VIEW");
assert_eq!(normalize_mysql_table_type("view"), "VIEW");
assert_eq!(normalize_mysql_table_type("BASE TABLE"), "BASE TABLE");
assert_eq!(normalize_mysql_table_type("MATERIALIZED VIEW"), "MATERIALIZED VIEW");
assert_eq!(normalize_mysql_table_type(""), "TABLE");
}
#[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_database_listing_prefers_show_databases() {
assert_eq!(
DATABASE_LIST_QUERY_PLAN,
[
("SHOW DATABASES", true),
("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME", false),
]
);
}
#[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 IN ('VIEW', 'SYSTEM VIEW')"));
assert!(sql.contains("CASE WHEN TABLE_TYPE IN ('VIEW', 'SYSTEM VIEW') THEN 'VIEW' ELSE 'TABLE' END"));
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 lists_triggers_and_events_via_information_schema() {
let sql = list_triggers_objects_sql("shop");
assert!(sql.contains("information_schema.TRIGGERS"));
assert!(sql.contains("TRIGGER_SCHEMA = 'shop'"));
let sql = list_events_objects_sql("shop");
assert!(sql.contains("information_schema.EVENTS"));
assert!(sql.contains("EVENT_SCHEMA = 'shop'"));
assert!(wants_trigger_objects(Some(&["TRIGGER".to_string()])));
assert!(!wants_trigger_objects(Some(&["TABLE".to_string()])));
assert!(wants_event_objects(Some(&["EVENT".to_string()])));
}
#[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"));
// TABLE_COLLATION is fetched separately via `table_collation_sql`; the LEFT JOIN onto
// information_schema.TABLES is intentionally avoided to keep MySQL 5.7 fast.
assert!(!sql.contains("information_schema.TABLES"));
assert!(!sql.contains("TABLE_COLLATION"));
assert!(!sql.contains("KEY_COLUMN_USAGE"));
assert!(!sql.contains("CONSTRAINT_NAME = 'PRIMARY'"));
assert!(sql.contains("COLUMN_KEY"));
assert!(sql.contains("DATA_TYPE"));
assert!(sql.contains("COLUMN_TYPE"));
assert!(!sql.contains("COLLATE"));
assert!(!sql.contains("AS ENUM_VALUES"));
}
#[test]
fn mysql_nullable_table_collation_uses_optional_string_conversion() {
let collation = mysql_async::from_value_opt::<Option<String>>(mysql_async::Value::NULL)
.expect("Option<String> must accept NULL MySQL metadata values");
assert_eq!(collation, None);
}
#[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_bad_file_descriptor_retries_without_tcp_keepalive() {
let error = "MySQL connection failed: Input/output error: Input/output error: Bad file descriptor (os error 9)";
assert!(mysql_error_should_retry_without_tcp_keepalive(error));
assert!(!mysql_error_should_retry_without_tcp_keepalive(
"MySQL connection failed: Connection reset by peer (os error 54)"
));
}
#[test]
fn mysql_tcp_keepalive_uses_milliseconds_not_seconds() {
assert_eq!(MYSQL_TCP_KEEPALIVE_MS, 30_000);
assert_eq!(
MySqlTcpKeepaliveMode::Enabled.duration(),
Some(Duration::from_millis(u64::from(MYSQL_TCP_KEEPALIVE_MS)))
);
assert_eq!(MySqlTcpKeepaliveMode::Disabled.duration(), None);
}
#[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_leave_catalog_to_database_specific_setup() {
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 ob_query_timeout = 30000000"]);
}
#[test]
fn mysql_setup_appends_database_specific_catalog_for_reverse_execution() {
let extra = vec!["SWITCH `paimon_catalog`".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", "SWITCH `paimon_catalog`"]);
}
#[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 catalog_setup_query_for_url_uses_database_specific_syntax() {
assert_eq!(
catalog_setup_query_for_url(MySqlCatalogDialect::Doris, "mysql://host:3306/clip?catalog=paimon_catalog"),
Some("SWITCH `paimon_catalog`".to_string())
);
assert_eq!(
catalog_setup_query_for_url(
MySqlCatalogDialect::StarRocks,
"mysql://host:3306/clip?catalog=paimon_catalog"
),
Some("SET CATALOG `paimon_catalog`".to_string())
);
assert_eq!(
catalog_setup_query_for_url(MySqlCatalogDialect::Doris, "mysql://host:3306/db?catalog=my%5Fcatalog"),
Some("SWITCH `my_catalog`".to_string())
);
}
#[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"]
);
}
}