6727 lines
257 KiB
Rust
6727 lines
257 KiB
Rust
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"]
|
||
);
|
||
}
|
||
}
|