8215 lines
318 KiB
Rust
8215 lines
318 KiB
Rust
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||
use futures::StreamExt;
|
||
use mysql_async::consts::{ColumnFlags, 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};
|
||
#[cfg(test)]
|
||
use std::future::Future;
|
||
use std::ops::Deref;
|
||
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::{
|
||
is_mysql_jdbc_tls_param, mysql_jdbc_tls_mode, ConnectionConfig, DatabaseConnectionInfo, DatabaseType,
|
||
MysqlJdbcTlsMode,
|
||
};
|
||
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, LargeValueCell, ObjectInfo, ObjectStatistics, QueryMessage, QueryResult,
|
||
SpatialColumnBuilder, TableInfo, TriggerInfo,
|
||
};
|
||
|
||
use super::file_validator::validate_file_path;
|
||
use crate::mysql_event_sql::MysqlEventInfo;
|
||
|
||
/// DBX-owned MySQL pool handle. The driver does not expose its configured
|
||
/// maximum, so retain that value beside the driver pool for checkout phase
|
||
/// classification instead of guessing it from aggregate metrics.
|
||
#[derive(Debug, Clone)]
|
||
pub struct MySqlPool {
|
||
inner: mysql_async::Pool,
|
||
max_connections: usize,
|
||
}
|
||
|
||
impl MySqlPool {
|
||
pub(crate) fn new<O>(opts: O, max_connections: usize) -> Self
|
||
where
|
||
mysql_async::Opts: TryFrom<O>,
|
||
<mysql_async::Opts as TryFrom<O>>::Error: std::error::Error,
|
||
{
|
||
Self { inner: mysql_async::Pool::new(opts), max_connections: max_connections.max(1) }
|
||
}
|
||
|
||
pub async fn disconnect(self) -> Result<(), mysql_async::Error> {
|
||
self.inner.disconnect().await
|
||
}
|
||
|
||
/// Returns whether both handles refer to the same underlying pool
|
||
/// generation. Pool options are not an identity: a reconnect creates a new
|
||
/// pool with identical options, and a late health probe for the old pool
|
||
/// must not be allowed to remove that replacement from routing.
|
||
pub(crate) fn is_same_pool(&self, other: &Self) -> bool {
|
||
std::sync::Arc::ptr_eq(&self.inner.metrics(), &other.inner.metrics())
|
||
}
|
||
}
|
||
|
||
impl Deref for MySqlPool {
|
||
type Target = mysql_async::Pool;
|
||
|
||
fn deref(&self) -> &Self::Target {
|
||
&self.inner
|
||
}
|
||
}
|
||
|
||
#[doc(hidden)]
|
||
pub trait MySqlPoolAccess {
|
||
fn driver_pool(&self) -> &mysql_async::Pool;
|
||
fn checkout_max_connections(&self) -> Option<usize>;
|
||
}
|
||
|
||
pub async fn get_event_info<P: MySqlPoolAccess + ?Sized>(
|
||
pool: &P,
|
||
database: &str,
|
||
name: &str,
|
||
) -> Result<MysqlEventInfo, String> {
|
||
let mut conn = get_conn_with_health_check(pool).await?;
|
||
let sql = format!(
|
||
"SELECT EVENT_SCHEMA, EVENT_NAME, DEFINER, TIME_ZONE, EVENT_TYPE, EXECUTE_AT, INTERVAL_VALUE, INTERVAL_FIELD, STARTS, ENDS, STATUS, ON_COMPLETION, EVENT_COMMENT, EVENT_DEFINITION, CREATED, LAST_ALTERED, LAST_EXECUTED FROM information_schema.EVENTS WHERE EVENT_SCHEMA = {} AND EVENT_NAME = {} LIMIT 1",
|
||
quote_value(database), quote_value(name)
|
||
);
|
||
let row = conn
|
||
.query_first::<mysql_async::Row, _>(sql)
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.ok_or_else(|| format!("MySQL event not found: {database}.{name}"))?;
|
||
Ok(MysqlEventInfo {
|
||
schema: get_str_by_name(&row, "EVENT_SCHEMA"),
|
||
name: get_str_by_name(&row, "EVENT_NAME"),
|
||
definer: get_opt_str(&row, "DEFINER"),
|
||
time_zone: get_opt_str(&row, "TIME_ZONE"),
|
||
event_type: get_opt_str(&row, "EVENT_TYPE"),
|
||
execute_at: get_opt_str(&row, "EXECUTE_AT"),
|
||
interval_value: get_opt_str(&row, "INTERVAL_VALUE"),
|
||
interval_field: get_opt_str(&row, "INTERVAL_FIELD"),
|
||
starts: get_opt_str(&row, "STARTS"),
|
||
ends: get_opt_str(&row, "ENDS"),
|
||
status: get_opt_str(&row, "STATUS"),
|
||
on_completion: get_opt_str(&row, "ON_COMPLETION"),
|
||
comment: get_opt_str(&row, "EVENT_COMMENT"),
|
||
event_body: get_opt_str(&row, "EVENT_DEFINITION"),
|
||
event_definition: get_opt_str(&row, "EVENT_DEFINITION"),
|
||
created_at: get_opt_str(&row, "CREATED"),
|
||
updated_at: get_opt_str(&row, "LAST_ALTERED"),
|
||
last_executed: get_opt_str(&row, "LAST_EXECUTED"),
|
||
source: None,
|
||
})
|
||
}
|
||
|
||
impl MySqlPoolAccess for MySqlPool {
|
||
fn driver_pool(&self) -> &mysql_async::Pool {
|
||
&self.inner
|
||
}
|
||
|
||
fn checkout_max_connections(&self) -> Option<usize> {
|
||
Some(self.max_connections)
|
||
}
|
||
}
|
||
|
||
impl MySqlPoolAccess for mysql_async::Pool {
|
||
fn driver_pool(&self) -> &mysql_async::Pool {
|
||
self
|
||
}
|
||
|
||
fn checkout_max_connections(&self) -> Option<usize> {
|
||
None
|
||
}
|
||
}
|
||
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> {
|
||
if super::doris::is_profile(&db_type, driver_profile) {
|
||
Some(MySqlCatalogDialect::Doris)
|
||
} else if super::starrocks::is_profile(&db_type, driver_profile) {
|
||
Some(MySqlCatalogDialect::StarRocks)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default)]
|
||
pub struct MySqlQueryDialect {
|
||
supports_admin_show_results: bool,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct MySqlQueryResult {
|
||
pub result: QueryResult,
|
||
pub large_value_cells: Vec<LargeValueCell>,
|
||
}
|
||
|
||
impl MySqlQueryResult {
|
||
fn exact(result: QueryResult) -> Self {
|
||
Self { result, large_value_cells: Vec::new() }
|
||
}
|
||
}
|
||
|
||
const MYSQL_RESULT_CELL_PREVIEW_MIN_BYTES: usize = 256;
|
||
const MYSQL_RESULT_CELL_PREVIEW_MAX_BYTES: usize = 8 * 1024;
|
||
|
||
impl MySqlQueryDialect {
|
||
pub fn for_connection(db_type: DatabaseType, driver_profile: Option<&str>) -> Self {
|
||
Self {
|
||
supports_admin_show_results: super::doris::is_profile(&db_type, driver_profile)
|
||
|| super::starrocks::is_profile(&db_type, driver_profile)
|
||
|| super::manticoresearch::is_profile(&db_type, driver_profile)
|
||
|| super::tidb::is_profile(&db_type, driver_profile),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub enum MySqlQueryStreamItem {
|
||
Columns { columns: Vec<String>, column_types: Vec<String> },
|
||
Row(Vec<serde_json::Value>),
|
||
}
|
||
|
||
pub(super) 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())
|
||
}
|
||
|
||
fn first_column_value<T>(row: &mysql_async::Row) -> Option<T>
|
||
where
|
||
T: mysql_async::prelude::FromValue,
|
||
{
|
||
row_get(row, 0)
|
||
}
|
||
|
||
async fn query_first_column<T>(conn: &mut mysql_async::Conn, sql: &str) -> Result<Option<T>, mysql_async::Error>
|
||
where
|
||
T: mysql_async::prelude::FromValue,
|
||
{
|
||
// Some MySQL-compatible servers return extra columns for scalar queries.
|
||
// Convert only column zero so mysql_async does not panic on the row width.
|
||
let row = conn.query_first::<mysql_async::Row, _>(sql).await?;
|
||
Ok(row.as_ref().and_then(first_column_value))
|
||
}
|
||
|
||
/// 字节转 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 query_first_column::<String>(conn, sql).await {
|
||
Ok(value) => value.and_then(nonblank),
|
||
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 get_opt_unsigned_metadata_string(row: &mysql_async::Row, name: &str) -> Option<String> {
|
||
row_get::<u64, _>(row, name)
|
||
.map(|value| value.to_string())
|
||
.or_else(|| {
|
||
row_get::<i64, _>(row, name).and_then(|value| u64::try_from(value).ok()).map(|value| value.to_string())
|
||
})
|
||
.or_else(|| {
|
||
get_opt_str(row, name)
|
||
.filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
|
||
.and_then(|value| value.parse::<u64>().ok().map(|parsed| parsed.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 {
|
||
let is_string = matches!(
|
||
column.column_type(),
|
||
ColumnType::MYSQL_TYPE_STRING | ColumnType::MYSQL_TYPE_VAR_STRING | ColumnType::MYSQL_TYPE_VARCHAR
|
||
);
|
||
if !is_string {
|
||
return false;
|
||
}
|
||
|
||
if is_mysql_binary_charset(column) {
|
||
return true;
|
||
}
|
||
|
||
// ShardingSphere Proxy 5.3.0 rewrites BINARY/VARBINARY result metadata to
|
||
// utf8mb4 CHAR/VARCHAR, but preserves BINARY_FLAG and adds UNSIGNED_FLAG
|
||
// to every string column. A real text column with a binary collation can
|
||
// carry BINARY_FLAG, so require both flags to avoid converting it to Hex.
|
||
let flags = column.flags();
|
||
flags.contains(ColumnFlags::BINARY_FLAG) && flags.contains(ColumnFlags::UNSIGNED_FLAG)
|
||
}
|
||
|
||
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 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 is_mysql_binary_string_column(column) {
|
||
"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 is_mysql_binary_string_column(column) {
|
||
"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 bytes_to_upper_hex(bytes: &[u8]) -> String {
|
||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||
let mut output = String::with_capacity(bytes.len() * 2);
|
||
for byte in bytes {
|
||
output.push(HEX[(byte >> 4) as usize] as char);
|
||
output.push(HEX[(byte & 0x0F) as usize] as char);
|
||
}
|
||
output
|
||
}
|
||
|
||
// Native MySQL geometry values normally begin with a four-byte little-endian
|
||
// SRID prefix. A bare WKB value can have 0 or 1 at byte four as well, so only
|
||
// treat that prefix as present when the remaining bytes parse as WKB.
|
||
fn mysql_geometry_wkb_parts(bytes: &[u8]) -> Option<(&[u8], u32)> {
|
||
if bytes.len() >= 5 && matches!(bytes[4], 0 | 1) {
|
||
let prefix: [u8; 4] = bytes[..4].try_into().ok()?;
|
||
if super::wkb::decode_wkb_geometry(&bytes[4..]).is_some() {
|
||
return Some((&bytes[4..], u32::from_le_bytes(prefix)));
|
||
}
|
||
}
|
||
super::wkb::decode_wkb_geometry(bytes).map(|_| (bytes, 0))
|
||
}
|
||
|
||
fn mysql_geometry_to_export_marker(bytes: &[u8]) -> Option<String> {
|
||
let (wkb, srid) = mysql_geometry_wkb_parts(bytes)?;
|
||
Some(format!("DBX_WKB:{srid}:{}", bytes_to_upper_hex(wkb)))
|
||
}
|
||
|
||
fn mysql_value_to_json_for_export(
|
||
row: &mysql_async::Row,
|
||
idx: usize,
|
||
spatial_as_wkb: bool,
|
||
) -> Result<serde_json::Value, String> {
|
||
if !spatial_as_wkb
|
||
|| !row.columns_ref().get(idx).is_some_and(|column| column.column_type() == ColumnType::MYSQL_TYPE_GEOMETRY)
|
||
{
|
||
return Ok(mysql_value_to_json(row, idx));
|
||
}
|
||
let Some(bytes) = row_get::<Vec<u8>, _>(row, idx) else {
|
||
return Ok(mysql_value_to_json(row, idx));
|
||
};
|
||
mysql_geometry_to_export_marker(&bytes)
|
||
.map(serde_json::Value::String)
|
||
.ok_or_else(|| "Cannot export MySQL geometry value as WKB".to_string())
|
||
}
|
||
|
||
fn decode_mysql_geometry(bytes: &[u8]) -> Option<super::wkb::DecodedGeometry> {
|
||
let (wkb, srid) = mysql_geometry_wkb_parts(bytes)?;
|
||
let mut geometry = super::wkb::decode_wkb_geometry(wkb)?;
|
||
if geometry.srid.is_none() {
|
||
geometry.srid = (srid != 0).then_some(srid);
|
||
}
|
||
Some(geometry)
|
||
}
|
||
|
||
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_result_cell_preview_bytes(
|
||
max_result_bytes: usize,
|
||
row_limit: usize,
|
||
columns: &[mysql_async::Column],
|
||
) -> usize {
|
||
let serialized_column_weight = columns
|
||
.iter()
|
||
.map(
|
||
|column| {
|
||
if is_mysql_blob_column(column) || is_mysql_binary_string_column(column) {
|
||
2usize
|
||
} else {
|
||
1usize
|
||
}
|
||
},
|
||
)
|
||
.sum::<usize>()
|
||
.max(1);
|
||
max_result_bytes
|
||
.checked_div(row_limit.max(1))
|
||
.and_then(|per_row| per_row.checked_div(serialized_column_weight))
|
||
.unwrap_or(MYSQL_RESULT_CELL_PREVIEW_MIN_BYTES)
|
||
.clamp(MYSQL_RESULT_CELL_PREVIEW_MIN_BYTES, MYSQL_RESULT_CELL_PREVIEW_MAX_BYTES)
|
||
}
|
||
|
||
fn mysql_column_can_use_bounded_preview(column: &mysql_async::Column) -> bool {
|
||
match column.column_type() {
|
||
ColumnType::MYSQL_TYPE_JSON
|
||
| ColumnType::MYSQL_TYPE_BLOB
|
||
| ColumnType::MYSQL_TYPE_LONG_BLOB
|
||
| ColumnType::MYSQL_TYPE_MEDIUM_BLOB => true,
|
||
ColumnType::MYSQL_TYPE_TINY_BLOB => false,
|
||
ColumnType::MYSQL_TYPE_STRING | ColumnType::MYSQL_TYPE_VAR_STRING | ColumnType::MYSQL_TYPE_VARCHAR => {
|
||
column.column_length() > 8 * 1024
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn mysql_bounded_value_preview(
|
||
row: &mysql_async::Row,
|
||
index: usize,
|
||
preview_bytes: usize,
|
||
protected_indexes: &HashSet<usize>,
|
||
) -> Option<(serde_json::Value, usize)> {
|
||
if protected_indexes.contains(&index) {
|
||
return None;
|
||
}
|
||
let column = row.columns_ref().get(index)?;
|
||
if !mysql_column_can_use_bounded_preview(column) {
|
||
return None;
|
||
}
|
||
let mysql_async::Value::Bytes(bytes) = row.as_ref(index)? else {
|
||
return None;
|
||
};
|
||
if bytes.len() <= preview_bytes {
|
||
return None;
|
||
}
|
||
|
||
let binary = is_mysql_binary_charset(column) || is_mysql_binary_string_column(column);
|
||
let end = if binary {
|
||
preview_bytes.min(bytes.len())
|
||
} else {
|
||
let mut end = preview_bytes.min(bytes.len());
|
||
while end > 0 && std::str::from_utf8(&bytes[..end]).is_err() {
|
||
end -= 1;
|
||
}
|
||
end
|
||
};
|
||
let mut value = if binary {
|
||
mysql_bytes_to_json(bytes[..end].to_vec(), column)
|
||
} else {
|
||
let text = String::from_utf8_lossy(&bytes[..end]);
|
||
serde_json::Value::String(fix_potential_double_encoding(&text))
|
||
};
|
||
if let serde_json::Value::String(text) = &mut value {
|
||
text.push_str("...");
|
||
}
|
||
Some((value, bytes.len()))
|
||
}
|
||
|
||
fn mysql_result_protected_column_indexes(
|
||
columns: &[mysql_async::Column],
|
||
protected_columns: &[String],
|
||
) -> HashSet<usize> {
|
||
let protected: HashSet<String> = protected_columns.iter().map(|column| column.to_ascii_lowercase()).collect();
|
||
let mut indexes = columns
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, column)| {
|
||
(column.flags().contains(ColumnFlags::PRI_KEY_FLAG)
|
||
|| protected.contains(&column.name_str().to_ascii_lowercase()))
|
||
.then_some(index)
|
||
})
|
||
.collect::<HashSet<_>>();
|
||
for (index, column) in columns.iter().enumerate() {
|
||
if column.name_str().to_ascii_uppercase().starts_with(crate::sql_dialect::DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX) {
|
||
indexes.insert(index);
|
||
if index > 0 {
|
||
indexes.insert(index - 1);
|
||
}
|
||
}
|
||
}
|
||
indexes
|
||
}
|
||
|
||
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_row_to_json_with_srids_and_previews(
|
||
row: &mysql_async::Row,
|
||
spatial_columns: &mut SpatialColumnBuilder,
|
||
row_index: usize,
|
||
preview_bytes: Option<usize>,
|
||
protected_indexes: &HashSet<usize>,
|
||
) -> (Vec<serde_json::Value>, Vec<Option<u32>>, Vec<LargeValueCell>) {
|
||
let mut srids = vec![None; row.len()];
|
||
let mut large_value_cells = Vec::new();
|
||
let values = (0..row.len())
|
||
.map(|index| {
|
||
let is_geometry = row
|
||
.columns_ref()
|
||
.get(index)
|
||
.is_some_and(|column| column.column_type() == ColumnType::MYSQL_TYPE_GEOMETRY);
|
||
if is_geometry {
|
||
let Some(bytes) = row_get::<Vec<u8>, _>(row, index) else {
|
||
spatial_columns.observe(index, None);
|
||
return serde_json::Value::Null;
|
||
};
|
||
return match decode_mysql_geometry(&bytes) {
|
||
Some(geometry) => {
|
||
spatial_columns.observe(index, geometry.srid);
|
||
srids[index] = geometry.srid;
|
||
serde_json::Value::String(geometry.wkt)
|
||
}
|
||
None => {
|
||
spatial_columns.observe(index, None);
|
||
super::binary_value_to_json(&bytes)
|
||
}
|
||
};
|
||
}
|
||
|
||
if let Some((preview, original_bytes)) =
|
||
preview_bytes.and_then(|limit| mysql_bounded_value_preview(row, index, limit, protected_indexes))
|
||
{
|
||
large_value_cells.push(LargeValueCell { row_index, column_index: index, original_bytes });
|
||
return preview;
|
||
}
|
||
mysql_value_to_json(row, index)
|
||
})
|
||
.collect();
|
||
(values, srids, large_value_cells)
|
||
}
|
||
|
||
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, max_connections))
|
||
}
|
||
|
||
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 query_first_column::<u8>(conn, "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;
|
||
};
|
||
let query = query.split('#').next().unwrap_or(query);
|
||
let has_native_tls_param = query.split('&').any(|segment| {
|
||
let key = segment.split_once('=').map(|(key, _)| key).unwrap_or(segment).trim();
|
||
key.eq_ignore_ascii_case("ssl-mode")
|
||
|| key.eq_ignore_ascii_case("sslmode")
|
||
|| key.eq_ignore_ascii_case("require_ssl")
|
||
});
|
||
let native_requires_ssl = 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"
|
||
))
|
||
});
|
||
native_requires_ssl
|
||
|| (!has_native_tls_param
|
||
&& matches!(
|
||
mysql_jdbc_tls_mode(Some(query)),
|
||
Some(MysqlJdbcTlsMode::Required | MysqlJdbcTlsMode::VerifyCa)
|
||
))
|
||
}
|
||
|
||
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;
|
||
};
|
||
let query = query.split('#').next().unwrap_or(query);
|
||
let has_native_tls_param = query.split('&').any(|segment| {
|
||
let key = segment.split_once('=').map(|(key, _)| key).unwrap_or(segment).trim();
|
||
key.eq_ignore_ascii_case("ssl-mode")
|
||
|| key.eq_ignore_ascii_case("sslmode")
|
||
|| key.eq_ignore_ascii_case("require_ssl")
|
||
});
|
||
let native_attempts_ssl = 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")
|
||
});
|
||
native_attempts_ssl
|
||
|| (!has_native_tls_param
|
||
&& matches!(
|
||
mysql_jdbc_tls_mode(Some(query)),
|
||
Some(MysqlJdbcTlsMode::Preferred | MysqlJdbcTlsMode::Required | MysqlJdbcTlsMode::VerifyCa)
|
||
))
|
||
}
|
||
|
||
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;
|
||
let has_native_tls_param = query.split('&').any(|segment| {
|
||
let key = segment.split_once('=').map(|(key, _)| key).unwrap_or(segment).trim();
|
||
key.eq_ignore_ascii_case("ssl-mode")
|
||
|| key.eq_ignore_ascii_case("sslmode")
|
||
|| key.eq_ignore_ascii_case("require_ssl")
|
||
});
|
||
let jdbc_tls_mode = mysql_jdbc_tls_mode(Some(query));
|
||
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_mysql_jdbc_tls_param(key) {
|
||
changed = true;
|
||
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 !has_native_tls_param {
|
||
match jdbc_tls_mode {
|
||
Some(MysqlJdbcTlsMode::Disabled) => filtered.push("require_ssl=false".to_string()),
|
||
Some(MysqlJdbcTlsMode::Preferred | MysqlJdbcTlsMode::Required) => {
|
||
filtered.push("require_ssl=true".to_string());
|
||
filtered.push("verify_ca=false".to_string());
|
||
filtered.push("verify_identity=false".to_string());
|
||
}
|
||
Some(MysqlJdbcTlsMode::VerifyCa) => {
|
||
filtered.push("require_ssl=true".to_string());
|
||
filtered.push("verify_ca=true".to_string());
|
||
filtered.push("verify_identity=false".to_string());
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
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_METADATA_SQL: &str = "SELECT s.SCHEMA_NAME, \
|
||
s.DEFAULT_CHARACTER_SET_NAME, \
|
||
s.DEFAULT_COLLATION_NAME, \
|
||
SUM(COALESCE(t.DATA_LENGTH, 0) + COALESCE(t.INDEX_LENGTH, 0)) AS SIZE_BYTES \
|
||
FROM information_schema.SCHEMATA s \
|
||
LEFT JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = s.SCHEMA_NAME \
|
||
GROUP BY s.SCHEMA_NAME, s.DEFAULT_CHARACTER_SET_NAME, s.DEFAULT_COLLATION_NAME \
|
||
ORDER BY s.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> {
|
||
list_databases_with_timeout(pool, super::connection_timeout()).await
|
||
}
|
||
|
||
pub async fn list_databases_with_timeout(pool: &MySqlPool, timeout: Duration) -> 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, timeout).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, timeout).await
|
||
}
|
||
}
|
||
}
|
||
|
||
pub async fn list_database_metadata(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, String> {
|
||
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
|
||
let result = match conn.query_iter(DATABASE_METADATA_SQL).await {
|
||
Ok(result) => result,
|
||
Err(error) => {
|
||
log::debug!("Falling back to database names after metadata query failed: {error}");
|
||
drop(conn);
|
||
return list_databases(pool).await;
|
||
}
|
||
};
|
||
let rows: Vec<mysql_async::Row> = match result.collect_and_drop().await {
|
||
Ok(rows) => rows,
|
||
Err(error) => {
|
||
log::debug!("Falling back to database names after metadata query failed: {error}");
|
||
drop(conn);
|
||
return list_databases(pool).await;
|
||
}
|
||
};
|
||
Ok(rows
|
||
.iter()
|
||
.filter_map(|row| {
|
||
let name = get_str(row, 0).trim().to_string();
|
||
(!name.is_empty()).then_some(DatabaseInfo {
|
||
name,
|
||
size_bytes: get_opt_i64(row, "SIZE_BYTES"),
|
||
default_charset: get_opt_str(row, "DEFAULT_CHARACTER_SET_NAME")
|
||
.filter(|value| !value.trim().is_empty()),
|
||
default_collation: get_opt_str(row, "DEFAULT_COLLATION_NAME").filter(|value| !value.trim().is_empty()),
|
||
..Default::default()
|
||
})
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
async fn list_databases_with_query(
|
||
pool: &MySqlPool,
|
||
sql: &str,
|
||
include_catalogless_when_blank: bool,
|
||
timeout: Duration,
|
||
) -> Result<Vec<DatabaseInfo>, String> {
|
||
let mut conn = get_conn_with_timeout(pool, 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_show_with_timeout(pool, super::connection_timeout()).await
|
||
}
|
||
|
||
pub async fn list_databases_show_with_timeout(
|
||
pool: &MySqlPool,
|
||
timeout: Duration,
|
||
) -> Result<Vec<DatabaseInfo>, String> {
|
||
list_databases_with_query(pool, SHOW_DATABASES_SQL, true, timeout).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, ..Default::default() })
|
||
})
|
||
.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(), ..Default::default() }];
|
||
}
|
||
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)]
|
||
pub(super) struct TableStatusMeta {
|
||
comment: Option<String>,
|
||
created_at: Option<String>,
|
||
updated_at: Option<String>,
|
||
auto_increment: Option<String>,
|
||
}
|
||
|
||
const MYSQL_FRESH_TABLE_STATUS_SESSION_SQL: &str = "/*!80000 SET SESSION information_schema_stats_expiry = 0 */";
|
||
|
||
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);
|
||
query_table_status_sql(pool, &sql).await
|
||
}
|
||
|
||
async fn query_table_status_sql(pool: &MySqlPool, sql: &str) -> Result<HashMap<String, TableStatusMeta>, String> {
|
||
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
|
||
query_table_status_sql_with_conn(&mut conn, sql).await
|
||
}
|
||
|
||
async fn query_table_status_sql_with_conn(
|
||
conn: &mut mysql_async::Conn,
|
||
sql: &str,
|
||
) -> Result<HashMap<String, TableStatusMeta>, String> {
|
||
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"),
|
||
auto_increment: get_opt_unsigned_metadata_string(row, "Auto_increment"),
|
||
},
|
||
)
|
||
})
|
||
.filter(|(name, _)| !name.is_empty())
|
||
.collect())
|
||
}
|
||
|
||
pub async fn get_table_auto_increment(pool: &MySqlPool, database: &str, table: &str) -> Result<Option<String>, String> {
|
||
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
|
||
// MySQL 8 caches SHOW TABLE STATUS statistics by default, including the
|
||
// counter after ALTER TABLE. The version comment is a no-op on MySQL 5.7.
|
||
if let Err(error) = conn.query_drop(MYSQL_FRESH_TABLE_STATUS_SESSION_SQL).await {
|
||
log::debug!("Failed to disable cached MySQL table statistics before reading AUTO_INCREMENT: {error}");
|
||
}
|
||
let status = query_table_status_sql_with_conn(&mut conn, &show_table_status_exact_sql(database, table)).await?;
|
||
Ok(status.into_values().next().and_then(|meta| meta.auto_increment))
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
fn logical_show_full_tables_sql(database: &str) -> String {
|
||
if database.trim().is_empty() {
|
||
"SHOW FULL TABLES".to_string()
|
||
} else {
|
||
format!("SHOW FULL TABLES FROM {}", quote_identifier(database))
|
||
}
|
||
}
|
||
|
||
fn table_infos_from_show_rows(rows: &[mysql_async::Row]) -> Vec<TableInfo> {
|
||
let mut tables = 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::<Vec<_>>();
|
||
tables.sort_by(|a, b| a.name.cmp(&b.name));
|
||
tables
|
||
}
|
||
|
||
pub async fn list_logical_tables_show(pool: &MySqlPool, database: &str) -> Result<Vec<TableInfo>, String> {
|
||
// Proxy logical names are authoritative here. Do not add SHOW TABLE STATUS: this hot
|
||
// path must replace the information_schema lookup with one metadata request.
|
||
let sql = logical_show_full_tables_sql(database);
|
||
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
|
||
let result = conn.query_iter(&sql).await.map_err(|error| error.to_string())?;
|
||
let rows = result.collect_and_drop::<mysql_async::Row>().await.map_err(|error| error.to_string())?;
|
||
Ok(table_infos_from_show_rows(&rows))
|
||
}
|
||
|
||
async fn list_logical_table_objects_show_filtered(
|
||
pool: &MySqlPool,
|
||
database: &str,
|
||
object_types: Option<&[String]>,
|
||
limit: Option<usize>,
|
||
offset: Option<usize>,
|
||
) -> Result<Vec<ObjectInfo>, String> {
|
||
let tables = list_logical_tables_show(pool, database).await?;
|
||
Ok(filter_table_objects_fallback(
|
||
table_infos_to_objects(tables, &HashMap::new(), database),
|
||
object_types,
|
||
limit,
|
||
offset,
|
||
))
|
||
}
|
||
|
||
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?;
|
||
list_table_names_show_filtered_with_conn(&mut conn, database, filter, exact_names).await
|
||
}
|
||
|
||
pub(super) async fn list_table_names_show_filtered_with_conn(
|
||
conn: &mut mysql_async::Conn,
|
||
database: &str,
|
||
filter: Option<&str>,
|
||
exact_names: &[String],
|
||
) -> Result<Vec<TableInfo>, String> {
|
||
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()))?;
|
||
Ok(table_infos_from_show_rows(&rows))
|
||
}
|
||
|
||
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_table_status_exact_sql(database: &str, table: &str) -> String {
|
||
let prefix = if database.trim().is_empty() {
|
||
"SHOW TABLE STATUS".to_string()
|
||
} else {
|
||
format!("SHOW TABLE STATUS FROM {}", quote_identifier(database))
|
||
};
|
||
format!("{prefix} WHERE Name = {}", quote_value(table))
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
pub(super) 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 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 {
|
||
list_routines_sql_for_query(database, object_types, limit, offset, RoutineMetadataQuery::Timestamps)
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum RoutineMetadataQuery {
|
||
Timestamps,
|
||
Legacy,
|
||
}
|
||
|
||
fn routine_metadata_queries() -> [RoutineMetadataQuery; 2] {
|
||
[RoutineMetadataQuery::Timestamps, RoutineMetadataQuery::Legacy]
|
||
}
|
||
|
||
fn list_routines_sql_for_query(
|
||
database: &str,
|
||
object_types: Option<&[String]>,
|
||
limit: Option<usize>,
|
||
offset: Option<usize>,
|
||
query: RoutineMetadataQuery,
|
||
) -> 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(", ");
|
||
let timestamps = match query {
|
||
RoutineMetadataQuery::Timestamps => "CREATED AS created_at, LAST_ALTERED AS updated_at",
|
||
RoutineMetadataQuery::Legacy => "NULL AS created_at, NULL AS updated_at",
|
||
};
|
||
format!(
|
||
"SELECT ROUTINE_NAME AS object_name, ROUTINE_TYPE AS object_type, NULL AS object_comment, \
|
||
{timestamps}, \
|
||
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),
|
||
)
|
||
}
|
||
|
||
async fn query_routine_rows(
|
||
conn: &mut mysql_async::Conn,
|
||
database: &str,
|
||
object_types: Option<&[String]>,
|
||
limit: Option<usize>,
|
||
offset: Option<usize>,
|
||
) -> Result<Vec<mysql_async::Row>, String> {
|
||
let mut last_error = None;
|
||
for query in routine_metadata_queries() {
|
||
let sql = match query {
|
||
RoutineMetadataQuery::Timestamps => list_routines_sql(database, object_types, limit, offset),
|
||
RoutineMetadataQuery::Legacy => {
|
||
list_routines_sql_for_query(database, object_types, limit, offset, RoutineMetadataQuery::Legacy)
|
||
}
|
||
};
|
||
let result = match conn.query_iter(&sql).await {
|
||
Ok(result) => result.collect_and_drop::<mysql_async::Row>().await,
|
||
Err(error) => Err(error),
|
||
};
|
||
match result {
|
||
Ok(rows) => return Ok(rows),
|
||
Err(error) => {
|
||
if query == RoutineMetadataQuery::Timestamps {
|
||
log::debug!(
|
||
"Routine timestamps are unavailable for database `{database}`; retrying legacy metadata: {error}"
|
||
);
|
||
}
|
||
last_error = Some(error.to_string());
|
||
}
|
||
}
|
||
}
|
||
Err(last_error.unwrap_or_else(|| "Routine metadata returned no result".to_string()))
|
||
}
|
||
|
||
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,
|
||
custom_type_kind: None,
|
||
has_members: 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"),
|
||
trigger: None,
|
||
xugu_type_members_expandable: None,
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
fn logical_table_supplemental_types(object_types: Option<&[String]>) -> Vec<String> {
|
||
["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"]
|
||
.into_iter()
|
||
.filter(|object_type| requested_object_type(object_types, object_type))
|
||
.map(str::to_string)
|
||
.collect()
|
||
}
|
||
|
||
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 {
|
||
match query_routine_rows(&mut conn, database, object_types, query_limit, query_offset).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);
|
||
}
|
||
}
|
||
}
|
||
|
||
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_objects_with_logical_tables(
|
||
pool: &MySqlPool,
|
||
database: &str,
|
||
object_types: Option<&[String]>,
|
||
limit: Option<usize>,
|
||
offset: Option<usize>,
|
||
) -> Result<PagedObjectList, String> {
|
||
if !wants_table_objects(object_types) {
|
||
return list_objects(pool, database, object_types, limit, offset).await;
|
||
}
|
||
|
||
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 =
|
||
list_logical_table_objects_show_filtered(pool, database, object_types, query_limit, query_offset).await?;
|
||
let supplemental_types = logical_table_supplemental_types(object_types);
|
||
if !supplemental_types.is_empty() {
|
||
objects.extend(list_objects(pool, database, Some(&supplemental_types), None, None).await?.objects);
|
||
}
|
||
|
||
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(super) 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,
|
||
custom_type_kind: None,
|
||
has_members: 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,
|
||
trigger: None,
|
||
xugu_type_members_expandable: None,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub(super) 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 rows = query_routine_rows(&mut conn, database, None, None, None).await?;
|
||
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();
|
||
|
||
match query_routine_rows(&mut conn, database, None, None, None).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),
|
||
}
|
||
|
||
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 generation_expressions_sql(database: &str, table: &str) -> String {
|
||
format!(
|
||
"SELECT COLUMN_NAME, GENERATION_EXPRESSION \
|
||
FROM information_schema.COLUMNS \
|
||
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
|
||
AND GENERATION_EXPRESSION IS NOT NULL AND GENERATION_EXPRESSION <> '' \
|
||
ORDER BY ORDINAL_POSITION",
|
||
quote_value(database),
|
||
quote_value(table),
|
||
)
|
||
}
|
||
|
||
fn mysql_generated_column_storage(extra: &str) -> Option<&'static str> {
|
||
let normalized = extra.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||
if normalized.contains("virtual generated") {
|
||
Some("VIRTUAL")
|
||
} else if normalized.contains("stored generated") {
|
||
Some("STORED")
|
||
} else if normalized.contains("persistent generated") {
|
||
Some("PERSISTENT")
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn mysql_generated_column_extra(extra: &str, expression: &str) -> Option<String> {
|
||
let storage = mysql_generated_column_storage(extra)?;
|
||
let expression = expression.trim();
|
||
if expression.is_empty() {
|
||
return None;
|
||
}
|
||
Some(format!("GENERATED ALWAYS AS ({expression}) {storage}"))
|
||
}
|
||
|
||
async fn enrich_mysql_generated_column_expressions(
|
||
conn: &mut mysql_async::Conn,
|
||
database: &str,
|
||
table: &str,
|
||
columns: &mut [ColumnInfo],
|
||
) {
|
||
if database.trim().is_empty()
|
||
|| !columns
|
||
.iter()
|
||
.any(|column| column.extra.as_deref().is_some_and(|extra| mysql_generated_column_storage(extra).is_some()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
let sql = generation_expressions_sql(database, table);
|
||
let rows = match conn.query_iter(&sql).await {
|
||
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
|
||
Ok(rows) => rows,
|
||
Err(error) => {
|
||
log::debug!("Failed to collect MySQL generated-column expressions with `{sql}`: {error}");
|
||
return;
|
||
}
|
||
},
|
||
Err(error) => {
|
||
log::debug!("Failed to read MySQL generated-column expressions with `{sql}`: {error}");
|
||
return;
|
||
}
|
||
};
|
||
let expressions = rows
|
||
.iter()
|
||
.filter_map(|row| {
|
||
let name = get_str_by_name(row, "COLUMN_NAME");
|
||
let expression = get_str_by_name(row, "GENERATION_EXPRESSION");
|
||
(!name.is_empty() && !expression.trim().is_empty()).then_some((name, expression))
|
||
})
|
||
.collect::<HashMap<_, _>>();
|
||
|
||
apply_mysql_generated_column_expressions(columns, &expressions);
|
||
}
|
||
|
||
fn apply_mysql_generated_column_expressions(columns: &mut [ColumnInfo], expressions: &HashMap<String, String>) {
|
||
for column in columns {
|
||
let Some(expression) = expressions.get(&column.name) else {
|
||
continue;
|
||
};
|
||
let Some(extra) = column.extra.as_deref() else {
|
||
continue;
|
||
};
|
||
if let Some(generated_extra) = mysql_generated_column_extra(extra, expression) {
|
||
column.extra = Some(generated_extra);
|
||
}
|
||
}
|
||
}
|
||
|
||
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(crate) 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<P>(pool: &P, database: &str, table: &str) -> Result<Vec<ColumnInfo>, String>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
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,
|
||
resolved_schema: None,
|
||
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;
|
||
}
|
||
|
||
enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await;
|
||
normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref());
|
||
Ok(columns)
|
||
}
|
||
|
||
pub async fn get_columns_show<P>(pool: &P, database: &str, table: &str) -> Result<Vec<ColumnInfo>, String>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
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"),
|
||
resolved_schema: None,
|
||
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();
|
||
enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await;
|
||
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<P>(pool: &P) -> Result<mysql_async::Conn, String>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
get_conn_with_health_check_with_timeout(pool, super::connection_timeout()).await
|
||
}
|
||
|
||
pub async fn get_conn_with_health_check_with_timeout<P>(
|
||
pool: &P,
|
||
timeout: Duration,
|
||
) -> Result<mysql_async::Conn, String>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
get_conn_with_health_check_with_cancel(pool, timeout, timeout, None).await
|
||
}
|
||
|
||
pub async fn get_conn_with_health_check_with_cancel<P>(
|
||
pool: &P,
|
||
timeout: Duration,
|
||
cleanup_timeout: Duration,
|
||
cancel_token: Option<&CancellationToken>,
|
||
) -> Result<mysql_async::Conn, String>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) struct MysqlCheckoutSnapshot {
|
||
pub(crate) connection_count: usize,
|
||
pub(crate) connections_in_pool: usize,
|
||
pub(crate) max_connections: usize,
|
||
}
|
||
|
||
pub(crate) type MysqlCheckoutStage = super::PoolCheckoutStage;
|
||
|
||
pub(crate) fn classify_mysql_checkout_stage(snapshot: MysqlCheckoutSnapshot) -> MysqlCheckoutStage {
|
||
if snapshot.connections_in_pool > 0 {
|
||
MysqlCheckoutStage::Recycle
|
||
} else if snapshot.connection_count < snapshot.max_connections {
|
||
MysqlCheckoutStage::Create
|
||
} else {
|
||
MysqlCheckoutStage::Wait
|
||
}
|
||
}
|
||
|
||
fn mysql_checkout_snapshot<P>(pool: &P, max_connections: usize) -> MysqlCheckoutSnapshot
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
let metrics = pool.driver_pool().metrics();
|
||
use std::sync::atomic::Ordering;
|
||
MysqlCheckoutSnapshot {
|
||
connection_count: metrics.connection_count.load(Ordering::Relaxed),
|
||
connections_in_pool: metrics.connections_in_pool.load(Ordering::Relaxed),
|
||
max_connections,
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn checkout_mysql_conn<P>(
|
||
pool: &P,
|
||
timeout: Duration,
|
||
) -> Result<mysql_async::Conn, super::PoolCheckoutError>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
// Capture the phase before entering the driver future. The phase is tied
|
||
// to this checkout's capacity decision, not to a later aggregate metric
|
||
// snapshot that may already include another request.
|
||
let stage = pool.checkout_max_connections().map_or(MysqlCheckoutStage::Unknown, |max_connections| {
|
||
classify_mysql_checkout_stage(mysql_checkout_snapshot(pool, max_connections))
|
||
});
|
||
match tokio::time::timeout(timeout, pool.driver_pool().get_conn()).await {
|
||
Ok(Ok(conn)) => Ok(conn),
|
||
Ok(Err(error)) => Err(super::PoolCheckoutError::Failed { database: "MySQL", stage, detail: error.to_string() }),
|
||
Err(_) => Err(super::PoolCheckoutError::Timeout { database: "MySQL", stage, timeout }),
|
||
}
|
||
}
|
||
|
||
async fn get_conn_with_timeout_and_cancel<P>(
|
||
pool: &P,
|
||
timeout: Duration,
|
||
cancel_token: Option<&CancellationToken>,
|
||
) -> Result<mysql_async::Conn, String>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
let get_future = async { checkout_mysql_conn(pool, timeout).await.map_err(|error| error.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<P>(pool: &P, timeout: Duration) -> Result<mysql_async::Conn, String>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
checkout_mysql_conn(pool, timeout).await.map_err(|error| error.to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
async fn connection_result_with_timeout<T, F>(timeout: Duration, future: F) -> Result<T, String>
|
||
where
|
||
F: Future<Output = Result<T, String>>,
|
||
{
|
||
tokio::time::timeout(timeout, future).await.map_err(|_| "MySQL get connection timed out".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,
|
||
}
|
||
}
|
||
|
||
/// Maps `SHOW WARNINGS` rows (`Level`, `Code`, `Message`) to query messages.
|
||
fn mysql_warning_rows_to_messages(rows: Vec<(String, u16, String)>) -> Vec<QueryMessage> {
|
||
rows.into_iter()
|
||
.map(|(level, code, message)| QueryMessage {
|
||
severity: level,
|
||
code: Some(code.to_string()),
|
||
message,
|
||
detail: None,
|
||
hint: None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Maps a non-empty OK-packet info string (e.g. `Records: 3 Duplicates: 0
|
||
/// Warnings: 1`) to an INFO query message.
|
||
fn mysql_info_message(info: &str) -> Option<QueryMessage> {
|
||
let info = info.trim();
|
||
if info.is_empty() {
|
||
return None;
|
||
}
|
||
Some(QueryMessage { severity: "INFO".to_string(), code: None, message: info.to_string(), detail: None, hint: None })
|
||
}
|
||
|
||
fn mysql_warnings_fallback_message(warnings: u16) -> QueryMessage {
|
||
QueryMessage {
|
||
severity: "Warning".to_string(),
|
||
code: None,
|
||
message: format!("{warnings} warning(s)"),
|
||
detail: None,
|
||
hint: None,
|
||
}
|
||
}
|
||
|
||
/// Builds the message list from an OK-packet info string plus the outcome of a
|
||
/// `SHOW WARNINGS` query: `None` when the query failed (MySQL-compatible
|
||
/// proxies such as Doris/StarRocks may not support it), `Some(rows)` with its
|
||
/// rows otherwise. An empty successful result still falls back to the
|
||
/// count-only message so a nonzero warning count is never silently dropped.
|
||
fn mysql_server_messages_from_warnings(
|
||
info: &str,
|
||
warnings: u16,
|
||
warning_rows: Option<Vec<(String, u16, String)>>,
|
||
) -> Vec<QueryMessage> {
|
||
let mut messages: Vec<QueryMessage> = mysql_info_message(info).into_iter().collect();
|
||
if warnings == 0 {
|
||
return messages;
|
||
}
|
||
match warning_rows {
|
||
Some(rows) if !rows.is_empty() => messages.extend(mysql_warning_rows_to_messages(rows)),
|
||
_ => messages.push(mysql_warnings_fallback_message(warnings)),
|
||
}
|
||
messages
|
||
}
|
||
|
||
/// Best-effort collection of server messages for a finished statement: the
|
||
/// OK-packet info string plus `SHOW WARNINGS` output when the server reported
|
||
/// warnings. `SHOW WARNINGS` runs on the same connection; all errors are
|
||
/// swallowed (MySQL-compatible proxies such as Doris/StarRocks may not support
|
||
/// it) and fall back to a count-only message.
|
||
async fn collect_mysql_server_messages(conn: &mut mysql_async::Conn, warnings: u16, info: &str) -> Vec<QueryMessage> {
|
||
let warning_rows = if warnings == 0 {
|
||
None
|
||
} else {
|
||
match conn.query_iter("SHOW WARNINGS").await {
|
||
Ok(result) => match result.try_collect_and_drop::<(String, u16, String)>().await {
|
||
Ok(rows) => rows.into_iter().collect::<Result<Vec<_>, _>>().ok(),
|
||
Err(_) => None,
|
||
},
|
||
Err(_) => None,
|
||
}
|
||
};
|
||
mysql_server_messages_from_warnings(info, warnings, warning_rows)
|
||
}
|
||
|
||
async fn execute_result_set_with_text_protocol_on_conn(
|
||
conn: &mut mysql_async::Conn,
|
||
sql: &str,
|
||
row_limit: usize,
|
||
max_rows: Option<usize>,
|
||
max_result_bytes: Option<usize>,
|
||
result_key_columns: &[String],
|
||
diagnostic_trace_id: Option<&str>,
|
||
start: Instant,
|
||
) -> Result<MySqlQueryResult, String> {
|
||
let diagnostics_enabled = diagnostic_trace_id.is_some() && log::log_enabled!(log::Level::Info);
|
||
let dispatch_started_at = diagnostics_enabled.then(Instant::now);
|
||
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
|
||
let dispatch_ms = dispatch_started_at.map_or(0, |started_at| started_at.elapsed().as_millis());
|
||
if !advance_to_result_set_with_columns(&mut result).await? {
|
||
let affected_rows = result.affected_rows();
|
||
let warnings = result.warnings();
|
||
let info = result.info().into_owned();
|
||
drop(result);
|
||
let messages = collect_mysql_server_messages(conn, warnings, &info).await;
|
||
return Ok(MySqlQueryResult::exact(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,
|
||
messages,
|
||
}));
|
||
}
|
||
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 preview_bytes =
|
||
max_result_bytes.map(|max_bytes| mysql_result_cell_preview_bytes(max_bytes, row_limit, result.columns_ref()));
|
||
let protected_indexes = mysql_result_protected_column_indexes(result.columns_ref(), result_key_columns);
|
||
|
||
if max_result_bytes.is_none() && 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())?;
|
||
// collect_and_drop consumed the result; warnings/info now reflect the
|
||
// trailing EOF/OK packet of the finished result set.
|
||
let warnings = conn.get_warnings();
|
||
let info = conn.info().into_owned();
|
||
let messages = collect_mysql_server_messages(conn, warnings, &info).await;
|
||
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(MySqlQueryResult::exact(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,
|
||
messages,
|
||
}));
|
||
}
|
||
|
||
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||
let mut spatial_values: Vec<Vec<Option<u32>>> = Vec::new();
|
||
let mut large_value_cells = Vec::new();
|
||
let mut truncated = false;
|
||
let mut row_read_time = Duration::ZERO;
|
||
let mut row_convert_time = Duration::ZERO;
|
||
let mut stream = result
|
||
.stream::<mysql_async::Row>()
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.ok_or_else(|| "Empty result set stream".to_string())?;
|
||
|
||
loop {
|
||
let read_started_at = diagnostics_enabled.then(Instant::now);
|
||
let next_row = stream.next().await;
|
||
if let Some(started_at) = read_started_at {
|
||
row_read_time += started_at.elapsed();
|
||
}
|
||
let Some(row) = next_row else { break };
|
||
let row = row.map_err(|e| e.to_string())?;
|
||
if result_rows.len() >= row_limit {
|
||
truncated = true;
|
||
break;
|
||
}
|
||
let row_index = result_rows.len();
|
||
let convert_started_at = diagnostics_enabled.then(Instant::now);
|
||
let (values, srids, mut row_large_values) = mysql_row_to_json_with_srids_and_previews(
|
||
&row,
|
||
&mut spatial_columns,
|
||
row_index,
|
||
preview_bytes,
|
||
&protected_indexes,
|
||
);
|
||
result_rows.push(values);
|
||
spatial_values.push(srids);
|
||
large_value_cells.append(&mut row_large_values);
|
||
if let Some(started_at) = convert_started_at {
|
||
row_convert_time += started_at.elapsed();
|
||
}
|
||
}
|
||
drop(stream);
|
||
|
||
// A truncated stream broke out of the row loop before this result set's
|
||
// terminator packet arrived, so `result.warnings()`/`result.info()` still
|
||
// hold the previous statement's values; skip capture instead of reporting
|
||
// stale messages.
|
||
let finalize_started_at = diagnostics_enabled.then(Instant::now);
|
||
let messages = if truncated {
|
||
drop(result);
|
||
Vec::new()
|
||
} else {
|
||
let warnings = result.warnings();
|
||
let info = result.info().into_owned();
|
||
drop(result);
|
||
collect_mysql_server_messages(conn, warnings, &info).await
|
||
};
|
||
if diagnostics_enabled {
|
||
log::info!(
|
||
"[query][mysql-result] trace_id={} protocol=text dispatch_ms={} row_read_ms={} row_convert_ms={} finalize_ms={} rows={} columns={} truncated={} preview_cells={}",
|
||
diagnostic_trace_id.unwrap_or("none"),
|
||
dispatch_ms,
|
||
row_read_time.as_millis(),
|
||
row_convert_time.as_millis(),
|
||
finalize_started_at.map_or(0, |started_at| started_at.elapsed().as_millis()),
|
||
result_rows.len(),
|
||
columns.len(),
|
||
truncated,
|
||
large_value_cells.len()
|
||
);
|
||
}
|
||
|
||
Ok(MySqlQueryResult {
|
||
result: 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,
|
||
messages,
|
||
},
|
||
large_value_cells,
|
||
})
|
||
}
|
||
|
||
async fn execute_result_sets_with_text_protocol_on_conn(
|
||
conn: &mut mysql_async::Conn,
|
||
sql: &str,
|
||
row_limit: usize,
|
||
max_rows: Option<usize>,
|
||
max_result_bytes: Option<usize>,
|
||
result_key_columns: &[String],
|
||
diagnostic_trace_id: Option<&str>,
|
||
start: Instant,
|
||
) -> Result<Vec<MySqlQueryResult>, String> {
|
||
// Per-set warnings/info read after each `collect()` are only accurate when
|
||
// the connection negotiated CLIENT_DEPRECATE_EOF: with legacy EOF packets
|
||
// the next result set's column-definition EOF clobbers the previous OK
|
||
// state, and a following column-less statement's OK packet would be
|
||
// misattributed to the wrong set. mysql_async does not expose the
|
||
// negotiated capability publicly, so gate on the client-side
|
||
// `deprecate_eof` option requested when the pool was built (DBX disables
|
||
// it automatically when a proxy only speaks legacy EOF). When disabled,
|
||
// per-set messages stay empty and only the final SHOW WARNINGS attachment
|
||
// on the last result set reports warnings.
|
||
let capture_per_set_messages = conn.opts().deprecate_eof();
|
||
let diagnostics_enabled = diagnostic_trace_id.is_some() && log::log_enabled!(log::Level::Info);
|
||
let dispatch_started_at = diagnostics_enabled.then(Instant::now);
|
||
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
|
||
let dispatch_ms = dispatch_started_at.map_or(0, |started_at| started_at.elapsed().as_millis());
|
||
let mut results: Vec<MySqlQueryResult> = Vec::new();
|
||
let mut result_set_warnings: Vec<u16> = Vec::new();
|
||
let mut row_read_time = Duration::ZERO;
|
||
let mut row_convert_time = Duration::ZERO;
|
||
|
||
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 large_value_cells = Vec::new();
|
||
let mut truncated = false;
|
||
let preview_bytes = max_result_bytes
|
||
.map(|max_bytes| mysql_result_cell_preview_bytes(max_bytes, row_limit, result.columns_ref()));
|
||
let protected_indexes = mysql_result_protected_column_indexes(result.columns_ref(), result_key_columns);
|
||
|
||
let rows = if max_result_bytes.is_none() && should_collect_text_result_set(sql, row_limit, max_rows) {
|
||
let read_started_at = diagnostics_enabled.then(Instant::now);
|
||
let rows: Vec<mysql_async::Row> = result.collect().await.map_err(|e| e.to_string())?;
|
||
if let Some(started_at) = read_started_at {
|
||
row_read_time += started_at.elapsed();
|
||
}
|
||
truncated = rows.len() > row_limit;
|
||
let convert_started_at = diagnostics_enabled.then(Instant::now);
|
||
let converted_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::<Vec<_>>();
|
||
if let Some(started_at) = convert_started_at {
|
||
row_convert_time += started_at.elapsed();
|
||
}
|
||
converted_rows
|
||
} 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())?;
|
||
|
||
loop {
|
||
let read_started_at = diagnostics_enabled.then(Instant::now);
|
||
let next_row = stream.next().await;
|
||
if let Some(started_at) = read_started_at {
|
||
row_read_time += started_at.elapsed();
|
||
}
|
||
let Some(row) = next_row else { break };
|
||
let row = row.map_err(|e| e.to_string())?;
|
||
if rows.len() < row_limit {
|
||
let row_index = rows.len();
|
||
let convert_started_at = diagnostics_enabled.then(Instant::now);
|
||
let (values, srids, mut row_large_values) = mysql_row_to_json_with_srids_and_previews(
|
||
&row,
|
||
&mut spatial_columns,
|
||
row_index,
|
||
preview_bytes,
|
||
&protected_indexes,
|
||
);
|
||
rows.push(values);
|
||
spatial_values.push(srids);
|
||
large_value_cells.append(&mut row_large_values);
|
||
if let Some(started_at) = convert_started_at {
|
||
row_convert_time += started_at.elapsed();
|
||
}
|
||
} else {
|
||
truncated = true;
|
||
}
|
||
}
|
||
rows
|
||
};
|
||
|
||
let warnings = if capture_per_set_messages { result.warnings() } else { 0 };
|
||
let messages: Vec<QueryMessage> = if capture_per_set_messages {
|
||
mysql_info_message(&result.info()).into_iter().collect()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
result_set_warnings.push(warnings);
|
||
results.push(MySqlQueryResult {
|
||
result: 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,
|
||
messages,
|
||
},
|
||
large_value_cells,
|
||
});
|
||
}
|
||
|
||
if results.is_empty() {
|
||
let finalize_started_at = diagnostics_enabled.then(Instant::now);
|
||
let affected_rows = result.affected_rows();
|
||
let warnings = result.warnings();
|
||
let info = result.info().into_owned();
|
||
drop(result);
|
||
let messages = collect_mysql_server_messages(conn, warnings, &info).await;
|
||
results.push(MySqlQueryResult::exact(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,
|
||
messages,
|
||
}));
|
||
if diagnostics_enabled {
|
||
log::info!(
|
||
"[query][mysql-result] trace_id={} protocol=text-multi dispatch_ms={} row_read_ms={} row_convert_ms={} finalize_ms={} result_count=1 row_counts=[0] column_counts=[0] truncated_sets=0 preview_cells=0",
|
||
diagnostic_trace_id.unwrap_or("none"),
|
||
dispatch_ms,
|
||
row_read_time.as_millis(),
|
||
row_convert_time.as_millis(),
|
||
finalize_started_at.map_or(0, |started_at| started_at.elapsed().as_millis())
|
||
);
|
||
}
|
||
return Ok(results);
|
||
}
|
||
// Without per-set capture (no CLIENT_DEPRECATE_EOF) the per-iteration
|
||
// counts were skipped above; the connection state after the loop still
|
||
// reflects the last statement's terminator, so use its warnings count for
|
||
// the final SHOW WARNINGS attachment on the last result set.
|
||
if !capture_per_set_messages {
|
||
let last_index = result_set_warnings.len() - 1;
|
||
result_set_warnings[last_index] = result.warnings();
|
||
}
|
||
let finalize_started_at = diagnostics_enabled.then(Instant::now);
|
||
drop(result);
|
||
|
||
// SHOW WARNINGS only reports the last executed statement, so detailed
|
||
// warning rows can only be attached to the last result set; earlier sets
|
||
// with warnings get a count-only fallback message.
|
||
let last_index = results.len() - 1;
|
||
for (index, warnings) in result_set_warnings.iter().copied().enumerate() {
|
||
if warnings == 0 {
|
||
continue;
|
||
}
|
||
if index == last_index {
|
||
let mut messages = collect_mysql_server_messages(conn, warnings, "").await;
|
||
results[index].result.messages.append(&mut messages);
|
||
} else {
|
||
results[index].result.messages.push(mysql_warnings_fallback_message(warnings));
|
||
}
|
||
}
|
||
|
||
if diagnostics_enabled {
|
||
log::info!(
|
||
"[query][mysql-result] trace_id={} protocol=text-multi dispatch_ms={} row_read_ms={} row_convert_ms={} finalize_ms={} result_count={} row_counts={:?} column_counts={:?} truncated_sets={} preview_cells={}",
|
||
diagnostic_trace_id.unwrap_or("none"),
|
||
dispatch_ms,
|
||
row_read_time.as_millis(),
|
||
row_convert_time.as_millis(),
|
||
finalize_started_at.map_or(0, |started_at| started_at.elapsed().as_millis()),
|
||
results.len(),
|
||
results.iter().map(|result| result.result.rows.len()).collect::<Vec<_>>(),
|
||
results.iter().map(|result| result.result.columns.len()).collect::<Vec<_>>(),
|
||
results.iter().filter(|result| result.result.truncated).count(),
|
||
results.iter().map(|result| result.large_value_cells.len()).sum::<usize>()
|
||
);
|
||
}
|
||
|
||
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,
|
||
max_result_bytes: Option<usize>,
|
||
result_key_columns: &[String],
|
||
diagnostic_trace_id: Option<&str>,
|
||
start: Instant,
|
||
) -> Result<MySqlQueryResult, String> {
|
||
let diagnostics_enabled = diagnostic_trace_id.is_some() && log::log_enabled!(log::Level::Info);
|
||
let dispatch_started_at = diagnostics_enabled.then(Instant::now);
|
||
let mut result = conn.exec_iter(sql, ()).await.map_err(|e| e.to_string())?;
|
||
let dispatch_ms = dispatch_started_at.map_or(0, |started_at| started_at.elapsed().as_millis());
|
||
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 preview_bytes =
|
||
max_result_bytes.map(|max_bytes| mysql_result_cell_preview_bytes(max_bytes, row_limit, result.columns_ref()));
|
||
let protected_indexes = mysql_result_protected_column_indexes(result.columns_ref(), result_key_columns);
|
||
|
||
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||
let mut spatial_values: Vec<Vec<Option<u32>>> = Vec::new();
|
||
let mut large_value_cells = Vec::new();
|
||
let mut truncated = false;
|
||
let mut row_read_time = Duration::ZERO;
|
||
let mut row_convert_time = Duration::ZERO;
|
||
let mut stream = result
|
||
.stream::<mysql_async::Row>()
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.ok_or_else(|| "Empty result set stream".to_string())?;
|
||
|
||
loop {
|
||
let read_started_at = diagnostics_enabled.then(Instant::now);
|
||
let next_row = stream.next().await;
|
||
if let Some(started_at) = read_started_at {
|
||
row_read_time += started_at.elapsed();
|
||
}
|
||
let Some(row) = next_row else { break };
|
||
let row = row.map_err(|e| e.to_string())?;
|
||
if result_rows.len() >= row_limit {
|
||
truncated = true;
|
||
break;
|
||
}
|
||
let row_index = result_rows.len();
|
||
let convert_started_at = diagnostics_enabled.then(Instant::now);
|
||
let (values, srids, mut row_large_values) = mysql_row_to_json_with_srids_and_previews(
|
||
&row,
|
||
&mut spatial_columns,
|
||
row_index,
|
||
preview_bytes,
|
||
&protected_indexes,
|
||
);
|
||
result_rows.push(values);
|
||
spatial_values.push(srids);
|
||
large_value_cells.append(&mut row_large_values);
|
||
if let Some(started_at) = convert_started_at {
|
||
row_convert_time += started_at.elapsed();
|
||
}
|
||
}
|
||
drop(stream);
|
||
|
||
// A truncated stream broke out of the row loop before this result set's
|
||
// terminator packet arrived, so `result.warnings()`/`result.info()` still
|
||
// hold the previous statement's values; skip capture instead of reporting
|
||
// stale messages.
|
||
let finalize_started_at = diagnostics_enabled.then(Instant::now);
|
||
let messages = if truncated {
|
||
drop(result);
|
||
Vec::new()
|
||
} else {
|
||
let warnings = result.warnings();
|
||
let info = result.info().into_owned();
|
||
drop(result);
|
||
collect_mysql_server_messages(conn, warnings, &info).await
|
||
};
|
||
if diagnostics_enabled {
|
||
log::info!(
|
||
"[query][mysql-result] trace_id={} protocol=prepared dispatch_ms={} row_read_ms={} row_convert_ms={} finalize_ms={} rows={} columns={} truncated={} preview_cells={}",
|
||
diagnostic_trace_id.unwrap_or("none"),
|
||
dispatch_ms,
|
||
row_read_time.as_millis(),
|
||
row_convert_time.as_millis(),
|
||
finalize_started_at.map_or(0, |started_at| started_at.elapsed().as_millis()),
|
||
result_rows.len(),
|
||
columns.len(),
|
||
truncated,
|
||
large_value_cells.len()
|
||
);
|
||
}
|
||
|
||
Ok(MySqlQueryResult {
|
||
result: 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,
|
||
messages,
|
||
},
|
||
large_value_cells,
|
||
})
|
||
}
|
||
|
||
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?;
|
||
max_allowed_packet_on_conn(&mut conn).await
|
||
}
|
||
|
||
pub(crate) async fn max_allowed_packet_on_conn(conn: &mut mysql_async::Conn) -> Result<u64, String> {
|
||
query_first_column::<u64>(conn, "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<P>(
|
||
pool: &P,
|
||
sql: &str,
|
||
bare: bool,
|
||
max_rows: Option<usize>,
|
||
dialect: MySqlQueryDialect,
|
||
) -> Result<QueryResult, String>
|
||
where
|
||
P: MySqlPoolAccess + ?Sized,
|
||
{
|
||
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, false, |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,
|
||
spatial_as_wkb: bool,
|
||
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, spatial_as_wkb, &mut on_item).await
|
||
} else {
|
||
match stream_query_result_prepared(conn, sql, row_limit, cancelled, spatial_as_wkb, &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, spatial_as_wkb, &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,
|
||
spatial_as_wkb: bool,
|
||
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_for_export(&row, i, spatial_as_wkb))
|
||
.collect::<Result<_, _>>()?;
|
||
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,
|
||
spatial_as_wkb: bool,
|
||
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_for_export(&row, i, spatial_as_wkb))
|
||
.collect::<Result<_, _>>()?;
|
||
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> {
|
||
execute_query_on_conn_with_limits(conn, sql, bare, max_rows, None, &[], dialect, None)
|
||
.await
|
||
.map(|result| result.result)
|
||
}
|
||
|
||
pub async fn execute_query_on_conn_with_limits(
|
||
conn: &mut mysql_async::Conn,
|
||
sql: &str,
|
||
bare: bool,
|
||
max_rows: Option<usize>,
|
||
max_result_bytes: Option<usize>,
|
||
result_key_columns: &[String],
|
||
dialect: MySqlQueryDialect,
|
||
diagnostic_trace_id: Option<&str>,
|
||
) -> Result<MySqlQueryResult, 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,
|
||
max_result_bytes,
|
||
result_key_columns,
|
||
diagnostic_trace_id,
|
||
start,
|
||
)
|
||
.await
|
||
} else {
|
||
match execute_result_set_with_prepared_protocol_on_conn(
|
||
conn,
|
||
sql,
|
||
row_limit,
|
||
max_result_bytes,
|
||
result_key_columns,
|
||
diagnostic_trace_id,
|
||
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,
|
||
max_result_bytes,
|
||
result_key_columns,
|
||
diagnostic_trace_id,
|
||
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 warnings = result.warnings();
|
||
let info = result.info().into_owned();
|
||
let drop_result = result.drop_result().await;
|
||
// Collect server messages before restoring the timestamp defaults: the
|
||
// restore issues `SET SESSION explicit_defaults_for_timestamp`, which
|
||
// clears the diagnostics area and would leave SHOW WARNINGS empty.
|
||
let messages = collect_mysql_server_messages(conn, warnings, &info).await;
|
||
restore_explicit_timestamp_defaults_for_query(conn, previous_explicit_timestamp_defaults).await;
|
||
drop_result.map_err(|e| e.to_string())?;
|
||
|
||
Ok(MySqlQueryResult::exact(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,
|
||
messages,
|
||
}))
|
||
}
|
||
}
|
||
|
||
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> {
|
||
execute_query_results_on_conn_with_limits(conn, sql, bare, max_rows, None, &[], dialect, None)
|
||
.await
|
||
.map(|results| results.into_iter().map(|result| result.result).collect())
|
||
}
|
||
|
||
pub async fn execute_query_results_on_conn_with_limits(
|
||
conn: &mut mysql_async::Conn,
|
||
sql: &str,
|
||
bare: bool,
|
||
max_rows: Option<usize>,
|
||
max_result_bytes: Option<usize>,
|
||
result_key_columns: &[String],
|
||
dialect: MySqlQueryDialect,
|
||
diagnostic_trace_id: Option<&str>,
|
||
) -> Result<Vec<MySqlQueryResult>, 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,
|
||
max_result_bytes,
|
||
result_key_columns,
|
||
diagnostic_trace_id,
|
||
start,
|
||
)
|
||
.await
|
||
} else {
|
||
execute_query_on_conn_with_limits(
|
||
conn,
|
||
sql,
|
||
bare,
|
||
max_rows,
|
||
max_result_bytes,
|
||
result_key_columns,
|
||
dialect,
|
||
diagnostic_trace_id,
|
||
)
|
||
.await
|
||
.map(|result| vec![result])
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub(crate) struct MySqlNonResultBatchOutcome {
|
||
pub results: Vec<QueryResult>,
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
/// Executes a chunk of statements that are known not to return rows using one
|
||
/// COM_QUERY packet. MySQL still executes every statement in order and exposes
|
||
/// one OK packet per statement, so affected-row counts and failure positions
|
||
/// remain attributable to the original statements without one network round
|
||
/// trip per statement.
|
||
pub(crate) async fn execute_non_result_batch_on_conn(
|
||
conn: &mut mysql_async::Conn,
|
||
sql: &str,
|
||
expected_results: usize,
|
||
on_result: &mut (dyn FnMut(usize, &QueryResult) + Send),
|
||
) -> Result<MySqlNonResultBatchOutcome, String> {
|
||
if expected_results == 0 {
|
||
return Ok(MySqlNonResultBatchOutcome { results: Vec::new(), error: None });
|
||
}
|
||
|
||
let start = Instant::now();
|
||
let capture_per_set_messages = conn.opts().deprecate_eof();
|
||
let previous_explicit_timestamp_defaults = enable_explicit_timestamp_defaults_for_query(conn, sql).await;
|
||
let mut result = match conn.query_iter(sql).await {
|
||
Ok(result) => result,
|
||
Err(error) => {
|
||
restore_explicit_timestamp_defaults_for_query(conn, previous_explicit_timestamp_defaults).await;
|
||
return Ok(MySqlNonResultBatchOutcome { results: Vec::new(), error: Some(error.to_string()) });
|
||
}
|
||
};
|
||
let mut results = Vec::with_capacity(expected_results);
|
||
let mut warning_counts = Vec::with_capacity(expected_results);
|
||
let mut error = None;
|
||
let mut previous_elapsed_ms = 0;
|
||
|
||
for statement_index in 0..expected_results {
|
||
let Some(columns) = result.columns() else {
|
||
error = Some(match result.collect::<mysql_async::Row>().await {
|
||
Err(next_error) => next_error.to_string(),
|
||
Ok(_) => format!(
|
||
"MySQL batch returned fewer results than expected (expected {expected_results}, received {statement_index})."
|
||
),
|
||
});
|
||
break;
|
||
};
|
||
if !columns.is_empty() {
|
||
error = Some(format!(
|
||
"MySQL batch statement {} unexpectedly returned rows; retry the statement separately.",
|
||
statement_index + 1
|
||
));
|
||
break;
|
||
}
|
||
|
||
let warnings = result.warnings();
|
||
let info = result.info().into_owned();
|
||
let messages =
|
||
if capture_per_set_messages { mysql_info_message(&info).into_iter().collect() } else { Vec::new() };
|
||
let elapsed_ms = start.elapsed().as_millis();
|
||
let current_result = 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: elapsed_ms.saturating_sub(previous_elapsed_ms),
|
||
truncated: false,
|
||
session_id: None,
|
||
has_more: false,
|
||
elasticsearch_raw_body: None,
|
||
messages,
|
||
};
|
||
|
||
// query_iter/next_set only expose this metadata after the server has
|
||
// returned the current statement's OK packet. Report it before collect,
|
||
// because collect also waits for the next statement's result.
|
||
on_result(statement_index, ¤t_result);
|
||
previous_elapsed_ms = elapsed_ms;
|
||
results.push(current_result);
|
||
warning_counts.push(warnings);
|
||
|
||
if let Err(next_error) = result.collect::<mysql_async::Row>().await {
|
||
error = Some(next_error.to_string());
|
||
break;
|
||
}
|
||
}
|
||
|
||
if let Err(drop_error) = result.drop_result().await {
|
||
if error.is_none() {
|
||
error = Some(drop_error.to_string());
|
||
}
|
||
}
|
||
if error.is_none() && !results.is_empty() {
|
||
let last_index = results.len() - 1;
|
||
for (index, warnings) in warning_counts.into_iter().enumerate() {
|
||
if warnings == 0 {
|
||
continue;
|
||
}
|
||
if index == last_index {
|
||
results[index].messages.extend(collect_mysql_server_messages(conn, warnings, "").await);
|
||
} else if results[index].messages.is_empty() {
|
||
results[index].messages.push(mysql_warnings_fallback_message(warnings));
|
||
}
|
||
}
|
||
}
|
||
restore_explicit_timestamp_defaults_for_query(conn, previous_explicit_timestamp_defaults).await;
|
||
|
||
Ok(MySqlNonResultBatchOutcome { results, error })
|
||
}
|
||
|
||
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 {
|
||
// MySQL 的表维护语句虽然不是 SELECT,但服务器会返回包含表名和执行结果的表格。
|
||
// 如果把它们当成普通写入语句,后续 drop_result 会直接丢弃这些返回行。
|
||
starts_with_executable_sql_keyword_for_database(
|
||
sql,
|
||
&[
|
||
"SELECT", "SHOW", "DESCRIBE", "EXPLAIN", "WITH", "CALL", "CHECKSUM", "ANALYZE", "CHECK", "OPTIMIZE",
|
||
"REPAIR",
|
||
],
|
||
DatabaseType::Mysql,
|
||
) || mysql_statement_returns_rows(sql)
|
||
|| is_xa_recover_query(sql)
|
||
|| dialect.supports_admin_show_results && is_admin_show_query(sql)
|
||
}
|
||
|
||
pub(crate) fn is_batchable_non_result_query(sql: &str, dialect: MySqlQueryDialect) -> bool {
|
||
!is_result_set_query(sql, dialect)
|
||
&& starts_with_executable_sql_keyword_for_database(
|
||
sql,
|
||
&["INSERT", "REPLACE", "UPDATE", "DELETE"],
|
||
DatabaseType::Mysql,
|
||
)
|
||
}
|
||
|
||
/// 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 is_xa_recover_query(sql: &str) -> bool {
|
||
let tokens = leading_sql_word_tokens(sql, 2);
|
||
tokens.first().is_some_and(|token| token == "xa") && tokens.get(1).is_some_and(|token| token == "recover")
|
||
}
|
||
|
||
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 {
|
||
match error {
|
||
mysql_async::Error::Server(server_error) if server_error.code == 1054 => true,
|
||
mysql_async::Error::Server(server_error)
|
||
if server_error.code == 3009 && server_error.state.eq_ignore_ascii_case("HY000") =>
|
||
{
|
||
// PolarDB-X/DRDS wraps the unknown EXPRESSION column in TDDL error
|
||
// 3009. Require the complete diagnostic so unrelated HY000 errors
|
||
// (for example permissions or connectivity failures) are not retried
|
||
// with a different metadata query.
|
||
let message = server_error.message.to_ascii_lowercase();
|
||
message.contains("column") && message.contains("expression") && message.contains("not found")
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
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()),
|
||
key_is_expression: Vec::new(),
|
||
});
|
||
index_position
|
||
};
|
||
|
||
let index_part = if include_expression {
|
||
get_opt_str(&row, "EXPRESSION")
|
||
.filter(|value| !value.trim().is_empty())
|
||
.map(|expression| format!("({})", expression.trim()))
|
||
.or_else(|| get_opt_str(&row, "COLUMN_NAME").filter(|value| !value.is_empty()))
|
||
} else {
|
||
get_opt_str(&row, "COLUMN_NAME").filter(|value| !value.is_empty())
|
||
};
|
||
if let Some(index_part) = index_part {
|
||
indexes[index_position].columns.push(index_part);
|
||
}
|
||
})
|
||
.await
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
Ok(indexes)
|
||
}
|
||
|
||
pub async fn show_create_table_ddl(pool: &MySqlPool, database: &str, table: &str) -> Result<String, String> {
|
||
let sql = format!("SHOW CREATE TABLE {}", quote_table_ref(database, table));
|
||
let mut conn = get_conn_with_health_check(pool).await?;
|
||
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
|
||
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
||
let row = rows.first().ok_or("DDL not found")?;
|
||
row.get_opt::<String, usize>(1)
|
||
.and_then(|result| result.ok())
|
||
.or_else(|| row.get_opt::<Vec<u8>, usize>(1).and_then(|result| result.ok()).map(bytes_to_string_lossy))
|
||
.ok_or_else(|| "Failed to read DDL".to_string())
|
||
}
|
||
|
||
pub async fn list_foreign_keys(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
|
||
let column_sql = format!(
|
||
"SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, \
|
||
REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME \
|
||
FROM information_schema.KEY_COLUMN_USAGE \
|
||
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
|
||
AND REFERENCED_TABLE_NAME IS NOT NULL \
|
||
ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION",
|
||
quote_value(database),
|
||
quote_value(table),
|
||
);
|
||
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
|
||
let column_result = conn.query_iter(&column_sql).await.map_err(|e| e.to_string())?;
|
||
let column_rows: Vec<mysql_async::Row> = column_result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
||
if column_rows.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
|
||
// MySQL 5.7 materializes information_schema tables without normal indexes.
|
||
// Avoid joining two metadata tables because the join can scan the entire catalog.
|
||
let rule_sql = format!(
|
||
"SELECT CONSTRAINT_NAME, UPDATE_RULE, DELETE_RULE \
|
||
FROM information_schema.REFERENTIAL_CONSTRAINTS \
|
||
WHERE CONSTRAINT_SCHEMA = {} AND TABLE_NAME = {}",
|
||
quote_value(database),
|
||
quote_value(table),
|
||
);
|
||
let rule_result = conn.query_iter(&rule_sql).await.map_err(|e| e.to_string())?;
|
||
let rule_rows: Vec<mysql_async::Row> = rule_result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
||
let rules = rule_rows
|
||
.iter()
|
||
.map(|row| {
|
||
(
|
||
get_str_by_name(row, "CONSTRAINT_NAME"),
|
||
(get_str_by_name(row, "UPDATE_RULE"), get_str_by_name(row, "DELETE_RULE")),
|
||
)
|
||
})
|
||
.collect::<HashMap<_, _>>();
|
||
|
||
Ok(column_rows
|
||
.iter()
|
||
.map(|row| {
|
||
let name = get_str_by_name(row, "CONSTRAINT_NAME");
|
||
let (on_update, on_delete) = rules.get(&name).cloned().unwrap_or_default();
|
||
ForeignKeyInfo {
|
||
name,
|
||
column: get_str_by_name(row, "COLUMN_NAME"),
|
||
ref_schema: Some(get_str_by_name(row, "REFERENCED_TABLE_SCHEMA")),
|
||
ref_table: get_str_by_name(row, "REFERENCED_TABLE_NAME"),
|
||
ref_column: get_str_by_name(row, "REFERENCED_COLUMN_NAME"),
|
||
on_update: Some(on_update).filter(|value| !value.is_empty()),
|
||
on_delete: Some(on_delete).filter(|value| !value.is_empty()),
|
||
}
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
pub async fn list_triggers(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
|
||
let sql = format!(
|
||
"SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING, ACTION_STATEMENT \
|
||
FROM information_schema.TRIGGERS \
|
||
WHERE TRIGGER_SCHEMA = {} AND EVENT_OBJECT_TABLE = {} \
|
||
ORDER BY TRIGGER_NAME",
|
||
quote_value(database),
|
||
quote_value(table),
|
||
);
|
||
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
|
||
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
|
||
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
||
|
||
Ok(rows
|
||
.iter()
|
||
.map(|row| TriggerInfo {
|
||
name: get_str_by_name(row, "TRIGGER_NAME"),
|
||
event: get_str_by_name(row, "EVENT_MANIPULATION"),
|
||
timing: get_str_by_name(row, "ACTION_TIMING"),
|
||
level: None,
|
||
condition: None,
|
||
language: None,
|
||
enabled: None,
|
||
valid: None,
|
||
comment: None,
|
||
created_at: None,
|
||
statement: Some(get_str_by_name(row, "ACTION_STATEMENT")).filter(|value| !value.is_empty()),
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use mysql_async::{consts::ColumnType, Column, Value};
|
||
use mysql_common::row::new_row;
|
||
|
||
fn mysql_test_row(values: Vec<Value>) -> mysql_async::Row {
|
||
let columns = values
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, _)| {
|
||
Column::new(ColumnType::MYSQL_TYPE_VAR_STRING).with_name(format!("column_{index}").as_bytes())
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.into();
|
||
new_row(values, columns)
|
||
}
|
||
|
||
fn mysql_test_row_with_columns(values: Vec<Value>, columns: Vec<Column>) -> mysql_async::Row {
|
||
new_row(values, columns.into())
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_first_column_value_ignores_extra_result_columns() {
|
||
let row = mysql_async::from_row::<mysql_async::Row>(mysql_test_row(vec![
|
||
Value::Bytes(Vec::new()),
|
||
Value::Bytes(Vec::new()),
|
||
]));
|
||
|
||
assert_eq!(first_column_value::<String>(&row), Some(String::new()));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn mysql_database_list_timeout_controls_checkout_deadline() {
|
||
let exact = connection_result_with_timeout(Duration::from_millis(100), async {
|
||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||
Ok::<_, String>("connection")
|
||
});
|
||
let adjacent = connection_result_with_timeout(Duration::from_millis(50), async {
|
||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||
Ok::<_, String>("connection")
|
||
});
|
||
let configured_five = connection_result_with_timeout(
|
||
Duration::from_millis(10),
|
||
std::future::pending::<Result<&'static str, String>>(),
|
||
);
|
||
|
||
let (exact, adjacent, configured_five) = tokio::join!(exact, adjacent, configured_five);
|
||
|
||
assert_eq!(exact, Ok("connection"));
|
||
assert_eq!(adjacent, Ok("connection"));
|
||
assert_eq!(configured_five, Err("MySQL get connection timed out".to_string()));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn mysql_checkout_fault_injection_classifies_full_pool_as_wait() {
|
||
assert!(tokio::time::timeout(Duration::from_millis(1), std::future::pending::<()>()).await.is_err());
|
||
assert_eq!(
|
||
classify_mysql_checkout_stage(MysqlCheckoutSnapshot {
|
||
connection_count: 1,
|
||
connections_in_pool: 0,
|
||
max_connections: 1,
|
||
}),
|
||
MysqlCheckoutStage::Wait
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn mysql_checkout_fault_injection_classifies_hung_connection_create() {
|
||
assert!(tokio::time::timeout(Duration::from_millis(1), std::future::pending::<()>()).await.is_err());
|
||
assert_eq!(
|
||
classify_mysql_checkout_stage(MysqlCheckoutSnapshot {
|
||
connection_count: 0,
|
||
connections_in_pool: 0,
|
||
max_connections: 2,
|
||
}),
|
||
MysqlCheckoutStage::Create
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn mysql_checkout_fault_injection_preserves_create_stage_when_handshake_hangs() {
|
||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||
let address = listener.local_addr().unwrap();
|
||
let server = tokio::spawn(async move {
|
||
let (_socket, _) = listener.accept().await.unwrap();
|
||
std::future::pending::<()>().await;
|
||
});
|
||
let pool_options =
|
||
mysql_async::PoolOpts::new().with_constraints(mysql_async::PoolConstraints::new(1, 2).unwrap());
|
||
let options = mysql_async::OptsBuilder::default()
|
||
.ip_or_hostname(address.ip().to_string())
|
||
.tcp_port(address.port())
|
||
.user(Some("fault-injection"))
|
||
.pass(Some("fault-injection"))
|
||
.pool_opts(Some(pool_options));
|
||
let pool = MySqlPool::new(options, 2);
|
||
|
||
let error = checkout_mysql_conn(&pool, Duration::from_millis(50)).await.unwrap_err();
|
||
|
||
assert!(matches!(error, super::super::PoolCheckoutError::Timeout { stage: MysqlCheckoutStage::Create, .. }));
|
||
server.abort();
|
||
let _ = tokio::time::timeout(Duration::from_secs(1), pool.disconnect()).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn mysql_checkout_raw_driver_pool_timeout_keeps_stage_unknown() {
|
||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||
let address = listener.local_addr().unwrap();
|
||
let server = tokio::spawn(async move {
|
||
let (_socket, _) = listener.accept().await.unwrap();
|
||
std::future::pending::<()>().await;
|
||
});
|
||
let options = mysql_async::OptsBuilder::default()
|
||
.ip_or_hostname(address.ip().to_string())
|
||
.tcp_port(address.port())
|
||
.user(Some("fault-injection"))
|
||
.pass(Some("fault-injection"));
|
||
let pool = mysql_async::Pool::new(options);
|
||
|
||
let error = checkout_mysql_conn(&pool, Duration::from_millis(50)).await.unwrap_err();
|
||
|
||
assert!(matches!(error, super::super::PoolCheckoutError::Timeout { stage: MysqlCheckoutStage::Unknown, .. }));
|
||
server.abort();
|
||
let _ = tokio::time::timeout(Duration::from_secs(1), pool.disconnect()).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires DBX_REVIEW_MYSQL_* environment variables"]
|
||
async fn mysql_checkout_fault_injection_preserves_wait_stage_when_real_pool_is_full() {
|
||
let host = std::env::var("DBX_REVIEW_MYSQL_HOST").expect("DBX_REVIEW_MYSQL_HOST is required");
|
||
let port = std::env::var("DBX_REVIEW_MYSQL_PORT").expect("DBX_REVIEW_MYSQL_PORT is required");
|
||
let user = std::env::var("DBX_REVIEW_MYSQL_USER").expect("DBX_REVIEW_MYSQL_USER is required");
|
||
let password = std::env::var("DBX_REVIEW_MYSQL_PASSWORD").expect("DBX_REVIEW_MYSQL_PASSWORD is required");
|
||
let database = std::env::var("DBX_REVIEW_MYSQL_DATABASE").expect("DBX_REVIEW_MYSQL_DATABASE is required");
|
||
let pool_options =
|
||
mysql_async::PoolOpts::new().with_constraints(mysql_async::PoolConstraints::new(1, 1).unwrap());
|
||
let options = mysql_async::OptsBuilder::default()
|
||
.ip_or_hostname(host)
|
||
.tcp_port(port.parse().expect("DBX_REVIEW_MYSQL_PORT must be a valid port"))
|
||
.user(Some(user))
|
||
.pass(Some(password))
|
||
.db_name(Some(database))
|
||
.pool_opts(Some(pool_options));
|
||
let pool = MySqlPool::new(options, 1);
|
||
let held_connection = tokio::time::timeout(Duration::from_secs(10), pool.get_conn())
|
||
.await
|
||
.expect("real MySQL checkout timed out")
|
||
.expect("real MySQL checkout failed");
|
||
|
||
let error = checkout_mysql_conn(&pool, Duration::from_millis(50)).await.unwrap_err();
|
||
|
||
assert!(matches!(error, super::super::PoolCheckoutError::Timeout { stage: MysqlCheckoutStage::Wait, .. }));
|
||
drop(held_connection);
|
||
let _ = tokio::time::timeout(Duration::from_secs(2), pool.disconnect()).await;
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_public_metadata_and_query_helpers_accept_driver_pool_for_compatibility() {
|
||
let pool = mysql_async::Pool::new(mysql_async::OptsBuilder::default());
|
||
let columns = get_columns(&pool, "database", "table");
|
||
let query = execute_query_with_max_rows(&pool, "SELECT 1", false, Some(1), MySqlQueryDialect::default());
|
||
|
||
drop((columns, query));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn mysql_database_list_timeout_preserves_immediate_errors_and_fallback_plan() {
|
||
let error =
|
||
connection_result_with_timeout(Duration::from_secs(10), async { Err::<(), _>("driver error".to_string()) })
|
||
.await;
|
||
|
||
assert_eq!(error, Err("driver error".to_string()));
|
||
assert_eq!(DATABASE_LIST_QUERY_PLAN, [(SHOW_DATABASES_SQL, true), (INFORMATION_SCHEMA_DATABASES_SQL, false)]);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_first_column_value_handles_null_and_conversion_failures() {
|
||
let null_row = mysql_test_row(vec![Value::NULL, Value::Bytes(b"ignored".to_vec())]);
|
||
let invalid_number_row = mysql_test_row(vec![Value::Bytes(b"not-a-number".to_vec())]);
|
||
|
||
assert_eq!(first_column_value::<String>(&null_row), None);
|
||
assert_eq!(first_column_value::<u64>(&invalid_number_row), None);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_first_column_value_reads_integer_metadata() {
|
||
let enabled_row = mysql_test_row(vec![Value::Int(1), Value::Bytes(b"ignored".to_vec())]);
|
||
let packet_row = mysql_test_row(vec![Value::UInt(67_108_864), Value::Bytes(b"ignored".to_vec())]);
|
||
|
||
assert_eq!(first_column_value::<u8>(&enabled_row), Some(1));
|
||
assert_eq!(first_column_value::<u64>(&packet_row), Some(67_108_864));
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_info_message_maps_non_empty_info_strings() {
|
||
let message = mysql_info_message("Records: 3 Duplicates: 0 Warnings: 1").unwrap();
|
||
assert_eq!(message.severity, "INFO");
|
||
assert_eq!(message.message, "Records: 3 Duplicates: 0 Warnings: 1");
|
||
assert_eq!(message.code, None);
|
||
assert_eq!(message.detail, None);
|
||
assert_eq!(message.hint, None);
|
||
|
||
assert!(mysql_info_message("").is_none());
|
||
assert!(mysql_info_message(" ").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_warning_rows_to_messages_maps_levels_and_codes() {
|
||
let messages = mysql_warning_rows_to_messages(vec![
|
||
("Warning".to_string(), 1265, "Data truncated for column 'a' at row 1".to_string()),
|
||
("Note".to_string(), 1051, "Unknown table 't'".to_string()),
|
||
]);
|
||
|
||
assert_eq!(messages.len(), 2);
|
||
assert_eq!(messages[0].severity, "Warning");
|
||
assert_eq!(messages[0].code.as_deref(), Some("1265"));
|
||
assert_eq!(messages[0].message, "Data truncated for column 'a' at row 1");
|
||
assert_eq!(messages[0].detail, None);
|
||
assert_eq!(messages[0].hint, None);
|
||
assert_eq!(messages[1].severity, "Note");
|
||
assert_eq!(messages[1].code.as_deref(), Some("1051"));
|
||
|
||
assert!(mysql_warning_rows_to_messages(Vec::new()).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_warnings_fallback_message_reports_count() {
|
||
let message = mysql_warnings_fallback_message(3);
|
||
assert_eq!(message.severity, "Warning");
|
||
assert_eq!(message.message, "3 warning(s)");
|
||
assert_eq!(message.code, None);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_server_messages_from_warnings_maps_info_and_warning_rows() {
|
||
let rows = vec![("Warning".to_string(), 1265, "Data truncated for column 'a' at row 1".to_string())];
|
||
let messages = mysql_server_messages_from_warnings("Records: 1 Duplicates: 0 Warnings: 1", 1, Some(rows));
|
||
|
||
assert_eq!(messages.len(), 2);
|
||
assert_eq!(messages[0].severity, "INFO");
|
||
assert_eq!(messages[1].severity, "Warning");
|
||
assert_eq!(messages[1].code.as_deref(), Some("1265"));
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_server_messages_from_warnings_falls_back_when_show_warnings_returns_no_rows() {
|
||
let messages = mysql_server_messages_from_warnings("", 2, Some(Vec::new()));
|
||
|
||
assert_eq!(messages.len(), 1);
|
||
assert_eq!(messages[0].severity, "Warning");
|
||
assert_eq!(messages[0].message, "2 warning(s)");
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_server_messages_from_warnings_falls_back_when_show_warnings_fails() {
|
||
let messages = mysql_server_messages_from_warnings("", 2, None);
|
||
|
||
assert_eq!(messages.len(), 1);
|
||
assert_eq!(messages[0].message, "2 warning(s)");
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_server_messages_from_warnings_skips_warnings_when_count_is_zero() {
|
||
let messages = mysql_server_messages_from_warnings("Query OK", 0, None);
|
||
|
||
assert_eq!(messages.len(), 1);
|
||
assert_eq!(messages[0].severity, "INFO");
|
||
assert_eq!(messages[0].message, "Query OK");
|
||
}
|
||
|
||
#[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,
|
||
custom_type_kind: None,
|
||
has_members: None,
|
||
comment: None,
|
||
created_at: None,
|
||
updated_at: None,
|
||
parent_schema: None,
|
||
parent_name: None,
|
||
trigger: None,
|
||
xugu_type_members_expandable: 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);
|
||
assert_eq!(
|
||
mysql_geometry_to_export_marker(&raw).as_deref(),
|
||
Some("DBX_WKB:0:0101000000000000000000F03F0000000000000040")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_geometry_export_marker_strips_internal_srid_prefix() {
|
||
let mut raw = 4326_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!(
|
||
mysql_geometry_to_export_marker(&raw).as_deref(),
|
||
Some("DBX_WKB:4326:0101000000000000000000F03F0000000000000040")
|
||
);
|
||
}
|
||
|
||
#[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_multi_statement_pipeline_only_accepts_known_dml() {
|
||
let dialect = MySqlQueryDialect::default();
|
||
|
||
assert!(is_batchable_non_result_query("INSERT INTO users(id) VALUES (1)", dialect));
|
||
assert!(is_batchable_non_result_query("UPDATE users SET active = 1", dialect));
|
||
assert!(!is_batchable_non_result_query("SELECT * FROM users", dialect));
|
||
assert!(!is_batchable_non_result_query("ANALYZE TABLE users", dialect));
|
||
assert!(!is_batchable_non_result_query("CREATE TABLE users(id INT)", 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_table_maintenance_queries_are_treated_as_result_sets() {
|
||
let dialect = MySqlQueryDialect::default();
|
||
|
||
for sql in [
|
||
"CHECKSUM TABLE `users`;",
|
||
"analyze table users",
|
||
"-- 检查表状态\nCHECK TABLE users",
|
||
"/* 整理表 */ OPTIMIZE TABLE users",
|
||
"repair table users quick",
|
||
] {
|
||
assert!(is_result_set_query(sql, dialect), "{sql}");
|
||
assert!(prefers_text_protocol_query(sql, dialect), "{sql}");
|
||
}
|
||
}
|
||
|
||
#[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 mysql_xa_recover_queries_are_treated_as_text_result_sets() {
|
||
let dialect = MySqlQueryDialect::default();
|
||
|
||
for sql in [
|
||
"XA RECOVER",
|
||
"xa recover convert xid;",
|
||
" -- inspect prepared transactions\nXa /* command */ ReCoVeR ;",
|
||
"# inspect prepared transactions\n/* before command */ XA\nRECOVER",
|
||
] {
|
||
assert!(is_result_set_query(sql, dialect), "{sql}");
|
||
assert!(prefers_text_protocol_query(sql, dialect), "{sql}");
|
||
}
|
||
|
||
for sql in [
|
||
"XA START 'dbx_xid'",
|
||
"XA BEGIN 'dbx_xid'",
|
||
"XA END 'dbx_xid'",
|
||
"XA PREPARE 'dbx_xid'",
|
||
"XA COMMIT 'dbx_xid'",
|
||
"XA ROLLBACK 'dbx_xid'",
|
||
] {
|
||
assert!(!is_result_set_query(sql, dialect), "{sql}");
|
||
}
|
||
}
|
||
|
||
#[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 tidb_admin_show_queries_are_treated_as_result_sets() {
|
||
let dialect = MySqlQueryDialect::for_connection(DatabaseType::Mysql, Some("tidb"));
|
||
|
||
for sql in [
|
||
"ADMIN SHOW DDL",
|
||
"ADMIN SHOW DDL JOBS 20",
|
||
"-- inspect DDL\nadmin /* TiDB */ show ddl jobs 20",
|
||
"ADMIN SHOW DDL JOB QUERIES 1",
|
||
] {
|
||
assert!(is_result_set_query(sql, dialect), "{sql}");
|
||
assert!(requires_text_protocol_query(sql, dialect), "{sql}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tidb_non_show_admin_statements_are_not_treated_as_result_sets() {
|
||
let dialect = MySqlQueryDialect::for_connection(DatabaseType::Mysql, Some("tidb"));
|
||
|
||
for sql in [
|
||
"ADMIN SET BDR ROLE PRIMARY",
|
||
"ADMIN CANCEL DDL JOBS 1",
|
||
"ADMIN PAUSE DDL JOBS 1",
|
||
"ADMIN RESUME DDL JOBS 1",
|
||
"ADMIN ALTER DDL JOBS 1 THREAD = 8",
|
||
] {
|
||
assert!(!is_result_set_query(sql, dialect), "{sql}");
|
||
assert!(!requires_text_protocol_query(sql, dialect), "{sql}");
|
||
}
|
||
}
|
||
|
||
#[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 unrelated_mysql_profiles_do_not_enable_admin_show_results() {
|
||
let sql = "ADMIN SHOW DDL JOBS 20";
|
||
|
||
for profile in ["mariadb", "oceanbase", "tdsql", "polardb", "greatsql", "custom"] {
|
||
let dialect = MySqlQueryDialect::for_connection(DatabaseType::Mysql, Some(profile));
|
||
|
||
assert!(!is_result_set_query(sql, dialect), "{profile}");
|
||
assert!(!requires_text_protocol_query(sql, dialect), "{profile}");
|
||
}
|
||
}
|
||
|
||
#[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 logical_show_full_tables_is_one_exact_statement() {
|
||
assert_eq!(logical_show_full_tables_sql("app"), "SHOW FULL TABLES FROM `app`");
|
||
assert_eq!(logical_show_full_tables_sql(""), "SHOW FULL TABLES");
|
||
}
|
||
|
||
#[test]
|
||
fn proxy_show_rows_keep_logical_names_and_types_without_comments() {
|
||
let rows = vec![
|
||
mysql_test_row(vec![Value::Bytes(b"normal_table".to_vec()), Value::Bytes(b"BASE TABLE".to_vec())]),
|
||
mysql_test_row(vec![Value::Bytes(b"t_order".to_vec()), Value::Bytes(b"BASE TABLE".to_vec())]),
|
||
mysql_test_row(vec![Value::Bytes(b"active_users".to_vec()), Value::Bytes(b"VIEW".to_vec())]),
|
||
];
|
||
|
||
let tables = table_infos_from_show_rows(&rows);
|
||
|
||
assert_eq!(
|
||
tables.iter().map(|table| (table.name.as_str(), table.table_type.as_str())).collect::<Vec<_>>(),
|
||
vec![("active_users", "VIEW"), ("normal_table", "BASE TABLE"), ("t_order", "BASE TABLE")]
|
||
);
|
||
assert!(tables.iter().all(|table| table.comment.is_none()));
|
||
}
|
||
|
||
#[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_table_status_auto_increment_is_lossless_and_nullable() {
|
||
let auto_increment_column = Column::new(ColumnType::MYSQL_TYPE_LONGLONG).with_name(b"Auto_increment");
|
||
let maximum = mysql_test_row_with_columns(vec![Value::UInt(u64::MAX)], vec![auto_increment_column.clone()]);
|
||
let null = mysql_test_row_with_columns(vec![Value::NULL], vec![auto_increment_column]);
|
||
|
||
assert_eq!(
|
||
get_opt_unsigned_metadata_string(&maximum, "Auto_increment").as_deref(),
|
||
Some("18446744073709551615")
|
||
);
|
||
assert_eq!(get_opt_unsigned_metadata_string(&null, "Auto_increment"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_exact_table_status_sql_quotes_identifiers_and_names() {
|
||
assert_eq!(
|
||
show_table_status_exact_sql("sales`archive", "order's"),
|
||
"SHOW TABLE STATUS FROM `sales``archive` WHERE Name = 'order\\'s'"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_table_status_refresh_directive_is_version_gated() {
|
||
assert!(MYSQL_FRESH_TABLE_STATUS_SESSION_SQL.starts_with("/*!80000 "));
|
||
assert!(MYSQL_FRESH_TABLE_STATUS_SESSION_SQL.contains("information_schema_stats_expiry = 0"));
|
||
}
|
||
|
||
#[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 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("CREATED AS created_at"));
|
||
assert!(sql.contains("LAST_ALTERED AS updated_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_routines_legacy_sql_preserves_filtering_and_paging() {
|
||
let object_types = vec!["FUNCTION".to_string()];
|
||
let sql =
|
||
list_routines_sql_for_query("app", Some(&object_types), Some(101), Some(200), RoutineMetadataQuery::Legacy);
|
||
|
||
assert!(sql.contains("NULL AS created_at, NULL AS updated_at"));
|
||
assert!(!sql.contains("CREATED AS created_at"));
|
||
assert!(sql.contains("ROUTINE_TYPE IN ('FUNCTION')"));
|
||
assert!(!sql.contains("ROUTINE_TYPE IN ('PROCEDURE')"));
|
||
assert!(sql.ends_with("LIMIT 101 OFFSET 200"));
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_routine_metadata_queries_are_bounded_to_one_fallback() {
|
||
assert_eq!(routine_metadata_queries(), [RoutineMetadataQuery::Timestamps, RoutineMetadataQuery::Legacy]);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_routine_row_preserves_creation_and_update_times() {
|
||
let columns = [
|
||
"object_name",
|
||
"object_type",
|
||
"object_comment",
|
||
"created_at",
|
||
"updated_at",
|
||
"parent_schema",
|
||
"parent_name",
|
||
"sort_order",
|
||
]
|
||
.into_iter()
|
||
.map(|name| Column::new(ColumnType::MYSQL_TYPE_VAR_STRING).with_name(name.as_bytes()))
|
||
.collect();
|
||
let row = mysql_test_row_with_columns(
|
||
vec![
|
||
Value::Bytes(b"refresh_orders".to_vec()),
|
||
Value::Bytes(b"PROCEDURE".to_vec()),
|
||
Value::NULL,
|
||
Value::Bytes(b"2026-08-13 04:00:00".to_vec()),
|
||
Value::Bytes(b"2026-08-13 04:05:00".to_vec()),
|
||
Value::NULL,
|
||
Value::NULL,
|
||
Value::Int(2),
|
||
],
|
||
columns,
|
||
);
|
||
|
||
let object = row_to_object(&row, "app");
|
||
|
||
assert_eq!(object.created_at.as_deref(), Some("2026-08-13 04:00:00"));
|
||
assert_eq!(object.updated_at.as_deref(), Some("2026-08-13 04:05:00"));
|
||
}
|
||
|
||
#[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 logical_table_objects_keep_only_requested_non_table_sources() {
|
||
assert_eq!(logical_table_supplemental_types(None), ["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"]);
|
||
assert!(logical_table_supplemental_types(Some(&["TABLE".to_string(), "VIEW".to_string()])).is_empty());
|
||
assert_eq!(
|
||
logical_table_supplemental_types(Some(&["TABLE".to_string(), "PROCEDURE".to_string()])),
|
||
["PROCEDURE"]
|
||
);
|
||
}
|
||
|
||
#[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_generation_expression_sql_is_separate_and_scoped() {
|
||
let sql = generation_expressions_sql("app", "products");
|
||
|
||
assert!(sql.contains("COLUMN_NAME, GENERATION_EXPRESSION"));
|
||
assert!(sql.contains("TABLE_SCHEMA = 'app'"));
|
||
assert!(sql.contains("TABLE_NAME = 'products'"));
|
||
assert!(sql.contains("GENERATION_EXPRESSION <> ''"));
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_generated_column_extra_rebuilds_virtual_and_stored_clauses() {
|
||
assert_eq!(
|
||
mysql_generated_column_extra("STORED GENERATED", "`price` * `quantity`"),
|
||
Some("GENERATED ALWAYS AS (`price` * `quantity`) STORED".to_string())
|
||
);
|
||
assert_eq!(
|
||
mysql_generated_column_extra("VIRTUAL GENERATED", "lower(`name`)"),
|
||
Some("GENERATED ALWAYS AS (lower(`name`)) VIRTUAL".to_string())
|
||
);
|
||
assert_eq!(mysql_generated_column_extra("DEFAULT_GENERATED", "current_timestamp()"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_generated_column_expressions_enrich_only_generated_columns() {
|
||
let mut columns = vec![
|
||
ColumnInfo { name: "total".to_string(), extra: Some("STORED GENERATED".to_string()), ..Default::default() },
|
||
ColumnInfo {
|
||
name: "search_name".to_string(),
|
||
extra: Some("VIRTUAL GENERATED".to_string()),
|
||
..Default::default()
|
||
},
|
||
ColumnInfo {
|
||
name: "created_at".to_string(),
|
||
extra: Some("DEFAULT_GENERATED".to_string()),
|
||
..Default::default()
|
||
},
|
||
];
|
||
let expressions = HashMap::from([
|
||
("total".to_string(), "`price` * `quantity`".to_string()),
|
||
("search_name".to_string(), "lower(`name`)".to_string()),
|
||
("created_at".to_string(), "current_timestamp()".to_string()),
|
||
]);
|
||
|
||
apply_mysql_generated_column_expressions(&mut columns, &expressions);
|
||
|
||
assert_eq!(columns[0].extra.as_deref(), Some("GENERATED ALWAYS AS (`price` * `quantity`) STORED"));
|
||
assert_eq!(columns[1].extra.as_deref(), Some("GENERATED ALWAYS AS (lower(`name`)) VIRTUAL"));
|
||
assert_eq!(columns[2].extra.as_deref(), Some("DEFAULT_GENERATED"));
|
||
}
|
||
|
||
#[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_shardingsphere_binary_flags_restore_proxy_binary_types() {
|
||
let proxy_flags = ColumnFlags::BINARY_FLAG | ColumnFlags::UNSIGNED_FLAG;
|
||
let binary_column = mysql_test_column(ColumnType::MYSQL_TYPE_STRING, 45, proxy_flags, 8);
|
||
let varbinary_column = mysql_test_column(ColumnType::MYSQL_TYPE_VAR_STRING, 45, proxy_flags, 32);
|
||
|
||
assert_eq!(mysql_column_type_name(&binary_column), "binary");
|
||
assert_eq!(mysql_column_type_name(&varbinary_column), "varbinary");
|
||
assert_eq!(
|
||
mysql_bytes_to_json(b"150010\0\0".to_vec(), &binary_column),
|
||
serde_json::json!("0x3135303031300000")
|
||
);
|
||
assert_eq!(
|
||
mysql_bytes_to_json(vec![0xde, 0xad, 0xbe, 0xef], &varbinary_column),
|
||
serde_json::json!("0xdeadbeef")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_shardingsphere_unsigned_text_flags_remain_text() {
|
||
let text_column = mysql_test_column(ColumnType::MYSQL_TYPE_VAR_STRING, 45, ColumnFlags::UNSIGNED_FLAG, 32);
|
||
|
||
assert_eq!(mysql_column_type_name(&text_column), "varchar");
|
||
assert_eq!(mysql_bytes_to_json(b"TEXT-001".to_vec(), &text_column), serde_json::json!("TEXT-001"));
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_binary_values_preserve_all_bytes_as_hex() {
|
||
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!("0x3135303031300000")
|
||
);
|
||
assert_eq!(mysql_bytes_to_json(b"150010".to_vec(), &varbinary_column), serde_json::json!("0x313530303130"));
|
||
assert_eq!(
|
||
mysql_bytes_to_json(b"SN-A0001".to_vec(), &varbinary_column),
|
||
serde_json::json!("0x534e2d4130303031")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_binary_values_preserve_unprintable_bytes_as_hex() {
|
||
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_large_text_preview_preserves_key_and_reports_cell_coordinates() {
|
||
let id_column = Column::new(ColumnType::MYSQL_TYPE_VAR_STRING)
|
||
.with_name(b"id")
|
||
.with_character_set(45)
|
||
.with_column_length(64);
|
||
let payload_column = Column::new(ColumnType::MYSQL_TYPE_LONG_BLOB)
|
||
.with_name(b"payload")
|
||
.with_character_set(45)
|
||
.with_column_length(u32::MAX);
|
||
let row = mysql_test_row_with_columns(
|
||
vec![Value::Bytes(vec![b'k'; 2048]), Value::Bytes(vec![b'x'; 32 * 1024])],
|
||
vec![id_column, payload_column],
|
||
);
|
||
let protected = mysql_result_protected_column_indexes(row.columns_ref(), &["id".to_string()]);
|
||
let mut spatial_columns = mysql_spatial_column_builder(row.columns_ref());
|
||
|
||
let (values, _srids, cells) =
|
||
mysql_row_to_json_with_srids_and_previews(&row, &mut spatial_columns, 7, Some(1024), &protected);
|
||
|
||
assert_eq!(values[0], serde_json::json!("k".repeat(2048)));
|
||
assert_eq!(values[1].as_str().map(str::len), Some(1027));
|
||
assert!(values[1].as_str().is_some_and(|value| value.ends_with("...")));
|
||
assert_eq!(cells, vec![LargeValueCell { row_index: 7, column_index: 1, original_bytes: 32 * 1024 }]);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_large_value_preview_protects_protocol_primary_keys_without_frontend_metadata() {
|
||
let id_column = Column::new(ColumnType::MYSQL_TYPE_VAR_STRING)
|
||
.with_name(b"id")
|
||
.with_character_set(45)
|
||
.with_flags(ColumnFlags::PRI_KEY_FLAG)
|
||
.with_column_length(3072);
|
||
let payload_column = Column::new(ColumnType::MYSQL_TYPE_JSON)
|
||
.with_name(b"payload")
|
||
.with_character_set(45)
|
||
.with_column_length(u32::MAX);
|
||
let row = mysql_test_row_with_columns(
|
||
vec![Value::Bytes(vec![b'k'; 2048]), Value::Bytes(vec![b'x'; 4096])],
|
||
vec![id_column, payload_column],
|
||
);
|
||
let protected = mysql_result_protected_column_indexes(row.columns_ref(), &[]);
|
||
let mut spatial_columns = mysql_spatial_column_builder(row.columns_ref());
|
||
|
||
let (values, _srids, cells) =
|
||
mysql_row_to_json_with_srids_and_previews(&row, &mut spatial_columns, 0, Some(512), &protected);
|
||
|
||
assert_eq!(values[0], serde_json::json!("k".repeat(2048)));
|
||
assert_eq!(cells, vec![LargeValueCell { row_index: 0, column_index: 1, original_bytes: 4096 }]);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_large_value_preview_skips_bounded_strings_and_server_preview_columns() {
|
||
let ordinary_column = Column::new(ColumnType::MYSQL_TYPE_VAR_STRING)
|
||
.with_name(b"image_url")
|
||
.with_character_set(45)
|
||
.with_column_length(2048);
|
||
let large_column = Column::new(ColumnType::MYSQL_TYPE_VAR_STRING)
|
||
.with_name(b"large_note")
|
||
.with_character_set(45)
|
||
.with_column_length(40_000);
|
||
let server_preview_column = Column::new(ColumnType::MYSQL_TYPE_VAR_STRING)
|
||
.with_name(b"image_data")
|
||
.with_character_set(63)
|
||
.with_column_length(1680);
|
||
let marker_column = Column::new(ColumnType::MYSQL_TYPE_VAR_STRING)
|
||
.with_name(format!("{}B_2", crate::sql_dialect::DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX).as_bytes())
|
||
.with_character_set(45)
|
||
.with_column_length(64);
|
||
let row = mysql_test_row_with_columns(
|
||
vec![
|
||
Value::Bytes(vec![b'u'; 500]),
|
||
Value::Bytes(vec![b'n'; 10_000]),
|
||
Value::Bytes(vec![0xab; 420]),
|
||
Value::Bytes(b"B:419:25143".to_vec()),
|
||
],
|
||
vec![ordinary_column, large_column, server_preview_column, marker_column],
|
||
);
|
||
let protected = mysql_result_protected_column_indexes(row.columns_ref(), &[]);
|
||
let mut spatial_columns = mysql_spatial_column_builder(row.columns_ref());
|
||
|
||
let (values, _srids, cells) =
|
||
mysql_row_to_json_with_srids_and_previews(&row, &mut spatial_columns, 0, Some(256), &protected);
|
||
|
||
assert_eq!(values[0].as_str().map(str::len), Some(500));
|
||
assert!(values[1].as_str().is_some_and(|value| value.ends_with("...")));
|
||
assert_eq!(values[2].as_str().map(str::len), Some(2 + 420 * 2));
|
||
assert_eq!(values[3], serde_json::json!("B:419:25143"));
|
||
assert_eq!(cells, vec![LargeValueCell { row_index: 0, column_index: 1, original_bytes: 10_000 }]);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_large_binary_preview_is_bounded_hex() {
|
||
let payload_column = Column::new(ColumnType::MYSQL_TYPE_LONG_BLOB)
|
||
.with_name(b"payload")
|
||
.with_character_set(63)
|
||
.with_flags(ColumnFlags::BLOB_FLAG)
|
||
.with_column_length(u32::MAX);
|
||
let row = mysql_test_row_with_columns(vec![Value::Bytes(vec![0xAB; 4096])], vec![payload_column]);
|
||
let mut spatial_columns = mysql_spatial_column_builder(row.columns_ref());
|
||
|
||
let (values, _srids, cells) =
|
||
mysql_row_to_json_with_srids_and_previews(&row, &mut spatial_columns, 0, Some(512), &HashSet::new());
|
||
|
||
let preview = values[0].as_str().unwrap();
|
||
assert!(preview.starts_with("0xabab"));
|
||
assert!(preview.ends_with("..."));
|
||
assert_eq!(preview.len(), 2 + 512 * 2 + 3);
|
||
assert_eq!(cells[0].original_bytes, 4096);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_result_preview_budget_scales_with_page_and_column_count() {
|
||
let text_column = mysql_test_column(ColumnType::MYSQL_TYPE_LONG_BLOB, 45, ColumnFlags::empty(), u32::MAX);
|
||
let binary_column = mysql_test_column(ColumnType::MYSQL_TYPE_LONG_BLOB, 63, ColumnFlags::BLOB_FLAG, u32::MAX);
|
||
assert_eq!(mysql_result_cell_preview_bytes(32 * 1024 * 1024, 1, std::slice::from_ref(&text_column)), 8 * 1024);
|
||
assert_eq!(mysql_result_cell_preview_bytes(32 * 1024 * 1024, 1000, &vec![text_column.clone(); 4]), 8 * 1024);
|
||
assert_eq!(mysql_result_cell_preview_bytes(32 * 1024 * 1024, 1000, &vec![text_column.clone(); 16]), 2097);
|
||
assert_eq!(mysql_result_cell_preview_bytes(1, 1000, &vec![text_column; 100]), 256);
|
||
assert_eq!(mysql_result_cell_preview_bytes(32 * 1024 * 1024, 1000, &vec![binary_column; 4]), 4194);
|
||
}
|
||
|
||
#[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_index_metadata_accepts_drds_expression_error_only_with_matching_message() {
|
||
let unsupported = mysql_async::Error::Server(mysql_async::ServerError {
|
||
code: 3009,
|
||
message: "[TDDL-4518][ERR_VALIDATE] Column 'EXPRESSION' not found".to_string(),
|
||
state: "HY000".to_string(),
|
||
});
|
||
let unrelated = mysql_async::Error::Server(mysql_async::ServerError {
|
||
code: 3009,
|
||
message: "[TDDL-4518][ERR_VALIDATE] Column 'INDEX_NAME' not found".to_string(),
|
||
state: "HY000".to_string(),
|
||
});
|
||
let wrong_state = mysql_async::Error::Server(mysql_async::ServerError {
|
||
code: 3009,
|
||
message: "[TDDL-4518][ERR_VALIDATE] Column 'EXPRESSION' not found".to_string(),
|
||
state: "42S22".to_string(),
|
||
});
|
||
|
||
assert!(mysql_statistics_expression_is_unsupported(&unsupported));
|
||
assert!(!mysql_statistics_expression_is_unsupported(&unrelated));
|
||
assert!(!mysql_statistics_expression_is_unsupported(&wrong_state));
|
||
}
|
||
|
||
#[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_translates_connector_j_tls_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?require_ssl=true&verify_ca=false&verify_identity=false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_async_url_translates_connector_j_required_verified_tls_params() {
|
||
let url = "mysql://host:3306/db?useSSL=true&requireSSL=true&verifyServerCertificate=true";
|
||
assert_eq!(
|
||
mysql_async_url(url).as_ref(),
|
||
"mysql://host:3306/db?require_ssl=true&verify_ca=true&verify_identity=false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_async_url_preserves_connector_j_tls_truth_table() {
|
||
assert_eq!(
|
||
mysql_async_url("mysql://host:3306/db?verifyServerCertificate=true").as_ref(),
|
||
"mysql://host:3306/db?require_ssl=true&verify_ca=true&verify_identity=false"
|
||
);
|
||
assert_eq!(
|
||
mysql_async_url("mysql://host:3306/db?useSSL=false&requireSSL=true&verifyServerCertificate=true").as_ref(),
|
||
"mysql://host:3306/db?require_ssl=false"
|
||
);
|
||
assert_eq!(
|
||
mysql_async_url("mysql://host:3306/db?requireSSL=false").as_ref(),
|
||
"mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_async_url_uses_last_connector_j_tls_param_value() {
|
||
assert_eq!(
|
||
mysql_async_url("mysql://host:3306/db?useSSL=false&useSSL=true").as_ref(),
|
||
"mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false"
|
||
);
|
||
assert_eq!(
|
||
mysql_async_url("mysql://host:3306/db?useSSL=true&useSSL=false&verifyServerCertificate=true").as_ref(),
|
||
"mysql://host:3306/db?require_ssl=false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mysql_async_url_prefers_native_tls_mode_over_connector_j_aliases() {
|
||
let url = "mysql://host:3306/db?sslMode=REQUIRED&useSSL=false&requireSSL=false";
|
||
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_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 connector_j_preferred_tls_falls_back_but_required_tls_does_not() {
|
||
assert_eq!(
|
||
ssl_fallback_url("mysql://host:3306/db?useSSL=true&characterEncoding=utf8"),
|
||
Some("mysql://host:3306/db?useSSL=true&characterEncoding=utf8&ssl-mode=disabled".to_string())
|
||
);
|
||
assert_eq!(ssl_fallback_url("mysql://host:3306/db?useSSL=true&requireSSL=true"), None);
|
||
assert_eq!(ssl_fallback_url("mysql://host:3306/db?verifyServerCertificate=true"), None);
|
||
let disabled = "mysql://host:3306/db?useSSL=false&requireSSL=true&verifyServerCertificate=true";
|
||
assert!(!mysql_url_requires_ssl(disabled));
|
||
assert!(!mysql_url_attempts_ssl(disabled));
|
||
}
|
||
|
||
#[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"]
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires a live ShardingSphere Proxy 5.3.0 fixture"]
|
||
async fn live_shardingsphere_proxy_preserves_binary_columns() {
|
||
let url = std::env::var("DBX_MYSQL_SHARDING_PROXY_URL")
|
||
.expect("DBX_MYSQL_SHARDING_PROXY_URL must point to the live proxy fixture");
|
||
let opts = mysql_async::Opts::from_url(&url).expect("valid MySQL proxy URL");
|
||
let pool = mysql_async::Pool::new(opts);
|
||
|
||
let columns =
|
||
get_columns(&pool, "dbx_sharding_proxy_test", "binary_samples").await.expect("load proxy column metadata");
|
||
let result = execute_query_with_max_rows(
|
||
&pool,
|
||
"SELECT id, fixed_value, variable_value, char_value, text_value, binary_collated \
|
||
FROM binary_samples ORDER BY id LIMIT 100",
|
||
false,
|
||
Some(100),
|
||
MySqlQueryDialect::default(),
|
||
)
|
||
.await
|
||
.expect("query proxy binary fixture");
|
||
pool.disconnect().await.expect("disconnect proxy pool");
|
||
|
||
assert_eq!(
|
||
columns.iter().map(|column| column.data_type.as_str()).collect::<Vec<_>>(),
|
||
vec!["int", "binary(8)", "varbinary(32)", "char(8)", "varchar(32)", "varchar(32)"]
|
||
);
|
||
assert_eq!(result.column_types, vec!["int", "binary", "varbinary", "char", "varchar", "varchar"]);
|
||
assert_eq!(
|
||
result.rows,
|
||
vec![
|
||
vec![
|
||
serde_json::json!("1"),
|
||
serde_json::json!("0x3135303031300000"),
|
||
serde_json::json!("0x534e2d4130303031"),
|
||
serde_json::json!("CHAR-001"),
|
||
serde_json::json!("TEXT-001"),
|
||
serde_json::json!("BIN-TEXT-001"),
|
||
],
|
||
vec![
|
||
serde_json::json!("2"),
|
||
serde_json::json!("0xdeadbeef00000000"),
|
||
serde_json::json!("0xdeadbeef"),
|
||
serde_json::json!("CHAR-002"),
|
||
serde_json::json!("TEXT-002"),
|
||
serde_json::json!("BIN-TEXT-002"),
|
||
],
|
||
vec![
|
||
serde_json::json!("3"),
|
||
serde_json::json!("0x0000000000000000"),
|
||
serde_json::json!("0x"),
|
||
serde_json::json!("CHAR-003"),
|
||
serde_json::json!("TEXT-003"),
|
||
serde_json::json!("BIN-TEXT-003"),
|
||
],
|
||
vec![
|
||
serde_json::json!("4"),
|
||
serde_json::json!("0x7f80810000000000"),
|
||
serde_json::json!("0x7f8081"),
|
||
serde_json::json!("CHAR-004"),
|
||
serde_json::json!("TEXT-004"),
|
||
serde_json::json!("BIN-TEXT-004"),
|
||
],
|
||
]
|
||
);
|
||
}
|
||
}
|