feat: auto-detect OceanBase Oracle mode and use Oracle-style schema queries (#155)

Introduce MysqlMode enum to distinguish Normal/Bare/OceanBaseOracle MySQL
connections. On connect, if driver_profile contains "oceanbase", detect
tenant mode via SHOW VARIABLES LIKE 'ob_compatibility_mode'. When Oracle
mode is detected, schema queries use ALL_TABLES/ALL_TAB_COLUMNS/etc.
This commit is contained in:
t8y2 2026-05-08 22:57:48 +08:00
parent 0e2fbc5a40
commit dfd4d2d99d
7 changed files with 286 additions and 20 deletions

View File

@ -17,8 +17,15 @@ pub fn expand_tilde(path: &str) -> String {
path.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MysqlMode {
Normal,
Bare,
OceanBaseOracle,
}
pub enum PoolKind {
Mysql(sqlx::mysql::MySqlPool, bool),
Mysql(sqlx::mysql::MySqlPool, MysqlMode),
Postgres(sqlx::postgres::PgPool),
Sqlite(sqlx::sqlite::SqlitePool),
Redis(tokio::sync::Mutex<redis::aio::MultiplexedConnection>),
@ -111,11 +118,15 @@ impl AppState {
let url = connection_url_for_endpoint(&db_config, &host, port);
let pool = match db_config.db_type {
DatabaseType::Mysql if db_config.needs_bare_mysql() => {
PoolKind::Mysql(db::mysql::connect_bare(&url).await?, true)
PoolKind::Mysql(db::mysql::connect_bare(&url).await?, MysqlMode::Bare)
}
DatabaseType::Mysql => {
let pool = db::mysql::connect(&url).await?;
let mode = detect_ob_oracle_mode(&db_config, &pool).await;
PoolKind::Mysql(pool, mode)
}
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?, false),
DatabaseType::Doris | DatabaseType::StarRocks => {
PoolKind::Mysql(db::mysql::connect_bare(&url).await?, true)
PoolKind::Mysql(db::mysql::connect_bare(&url).await?, MysqlMode::Bare)
}
DatabaseType::Postgres | DatabaseType::Redshift => PoolKind::Postgres(db::postgres::connect(&url).await?),
DatabaseType::Sqlite => PoolKind::Sqlite(db::sqlite::connect_path(&expand_tilde(&db_config.host)).await?),
@ -288,3 +299,17 @@ pub async fn probe_connection_endpoint(config: &ConnectionConfig, host: &str, po
_ => db::probe_tcp_endpoint(&format!("{:?}", config.db_type), host, port).await,
}
}
async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &sqlx::mysql::MySqlPool) -> MysqlMode {
let profile = config.driver_profile.as_deref().unwrap_or("").to_lowercase();
if !profile.contains("oceanbase") {
return MysqlMode::Normal;
}
match sqlx::query_as::<_, (String, String)>("SHOW VARIABLES LIKE 'ob_compatibility_mode'")
.fetch_optional(pool)
.await
{
Ok(Some((_, val))) if val.to_lowercase() == "oracle" => MysqlMode::OceanBaseOracle,
_ => MysqlMode::Normal,
}
}

View File

@ -6,6 +6,7 @@ pub mod file_validator;
pub mod gaussdb_driver;
pub mod mongo_driver;
pub mod mysql;
pub mod ob_oracle;
pub mod oracle_driver;
pub mod postgres;
pub mod redis_driver;

View File

@ -0,0 +1,200 @@
use sqlx::mysql::{MySqlPool, MySqlRow};
use sqlx::Row;
use crate::types::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, TableInfo, TriggerInfo};
fn quote_value(s: &str) -> String {
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
}
fn get_str(row: &MySqlRow, idx: usize) -> String {
row.try_get::<String, _>(idx)
.or_else(|_| row.try_get::<Vec<u8>, _>(idx).map(|b| String::from_utf8_lossy(&b).to_string()))
.unwrap_or_default()
}
fn get_opt_str(row: &MySqlRow, idx: usize) -> Option<String> {
row.try_get::<Option<String>, _>(idx).ok().flatten().or_else(|| {
row.try_get::<Option<Vec<u8>>, _>(idx).ok().flatten().map(|b| String::from_utf8_lossy(&b).to_string())
})
}
fn get_opt_i32(row: &MySqlRow, idx: usize) -> Option<i32> {
row.try_get::<Option<i32>, _>(idx)
.ok()
.flatten()
.or_else(|| row.try_get::<Option<i64>, _>(idx).ok().flatten().and_then(|v| i32::try_from(v).ok()))
}
pub async fn list_databases(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::raw_sql(
"SELECT USERNAME FROM ALL_USERS \
WHERE USERNAME NOT IN ('SYS','LBACSYS','ORAAUDITOR','__public') \
ORDER BY USERNAME",
)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| DatabaseInfo { name: get_str(row, 0) }).collect())
}
pub async fn list_tables(pool: &MySqlPool, schema: &str) -> Result<Vec<TableInfo>, String> {
let sql = format!(
"SELECT TABLE_NAME, 'TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = {s} \
UNION ALL \
SELECT VIEW_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE OWNER = {s} \
ORDER BY 1",
s = quote_value(schema),
);
let rows: Vec<MySqlRow> = sqlx::raw_sql(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| TableInfo { name: get_str(row, 0), table_type: get_str(row, 1), comment: None }).collect())
}
pub async fn get_columns(pool: &MySqlPool, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let sql = format!(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_DEFAULT, \
c.DATA_LENGTH, c.DATA_PRECISION, c.DATA_SCALE, c.COLUMN_ID, \
CASE WHEN cc.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK \
FROM ALL_TAB_COLUMNS c \
LEFT JOIN ( \
SELECT cols.OWNER, cols.TABLE_NAME, cols.COLUMN_NAME \
FROM ALL_CONS_COLUMNS cols \
JOIN ALL_CONSTRAINTS con ON con.CONSTRAINT_NAME = cols.CONSTRAINT_NAME AND con.OWNER = cols.OWNER \
WHERE con.CONSTRAINT_TYPE = 'P' \
) cc ON cc.OWNER = c.OWNER AND cc.TABLE_NAME = c.TABLE_NAME AND cc.COLUMN_NAME = c.COLUMN_NAME \
WHERE c.OWNER = {s} AND c.TABLE_NAME = {t} \
ORDER BY c.COLUMN_ID",
s = quote_value(schema),
t = quote_value(table),
);
let rows: Vec<MySqlRow> = sqlx::raw_sql(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
let data_type = get_str(row, 1);
let precision = get_opt_i32(row, 5);
let scale = get_opt_i32(row, 6);
let length = get_opt_i32(row, 4);
let display_type = format_oracle_type(&data_type, precision, scale, length);
ColumnInfo {
name: get_str(row, 0),
data_type: display_type,
is_nullable: get_str(row, 2) == "Y",
column_default: get_opt_str(row, 3).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
is_primary_key: row.try_get::<i32, _>(8).unwrap_or(0) == 1,
extra: None,
comment: None,
numeric_precision: precision,
numeric_scale: scale,
character_maximum_length: length,
}
})
.collect())
}
fn format_oracle_type(data_type: &str, precision: Option<i32>, scale: Option<i32>, length: Option<i32>) -> String {
match data_type.to_uppercase().as_str() {
"NUMBER" => match (precision, scale) {
(Some(p), Some(s)) if s > 0 => format!("NUMBER({p},{s})"),
(Some(p), _) => format!("NUMBER({p})"),
_ => "NUMBER".to_string(),
},
"VARCHAR2" | "NVARCHAR2" | "CHAR" | "NCHAR" | "RAW" => match length {
Some(l) => format!("{data_type}({l})"),
None => data_type.to_string(),
},
_ => data_type.to_string(),
}
}
pub async fn list_indexes(pool: &MySqlPool, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let sql = format!(
"SELECT ai.INDEX_NAME, \
LISTAGG(aic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY aic.COLUMN_POSITION) AS COLUMNS, \
ai.UNIQUENESS, \
CASE WHEN ac.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PRIMARY \
FROM ALL_INDEXES ai \
JOIN ALL_IND_COLUMNS aic ON ai.INDEX_NAME = aic.INDEX_NAME AND ai.TABLE_OWNER = aic.TABLE_OWNER \
LEFT JOIN ALL_CONSTRAINTS ac ON ac.INDEX_NAME = ai.INDEX_NAME AND ac.OWNER = ai.TABLE_OWNER AND ac.CONSTRAINT_TYPE = 'P' \
WHERE ai.TABLE_OWNER = {s} AND ai.TABLE_NAME = {t} \
GROUP BY ai.INDEX_NAME, ai.UNIQUENESS, ac.CONSTRAINT_TYPE \
ORDER BY ai.INDEX_NAME",
s = quote_value(schema),
t = quote_value(table),
);
let rows: Vec<MySqlRow> = sqlx::raw_sql(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
let cols_str = get_str(row, 1);
IndexInfo {
name: get_str(row, 0),
columns: cols_str.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(),
is_unique: get_str(row, 2) == "UNIQUE",
is_primary: row.try_get::<i32, _>(3).unwrap_or(0) == 1,
filter: None,
index_type: None,
included_columns: None,
comment: None,
}
})
.collect())
}
pub async fn list_foreign_keys(pool: &MySqlPool, schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let sql = format!(
"SELECT ac.CONSTRAINT_NAME, acc.COLUMN_NAME, \
ac2.TABLE_NAME AS R_TABLE, acc2.COLUMN_NAME AS R_COLUMN \
FROM ALL_CONSTRAINTS ac \
JOIN ALL_CONS_COLUMNS acc ON ac.CONSTRAINT_NAME = acc.CONSTRAINT_NAME AND ac.OWNER = acc.OWNER \
JOIN ALL_CONSTRAINTS ac2 ON ac.R_CONSTRAINT_NAME = ac2.CONSTRAINT_NAME AND ac.R_OWNER = ac2.OWNER \
JOIN ALL_CONS_COLUMNS acc2 ON ac2.CONSTRAINT_NAME = acc2.CONSTRAINT_NAME AND ac2.OWNER = acc2.OWNER \
AND acc.POSITION = acc2.POSITION \
WHERE ac.CONSTRAINT_TYPE = 'R' AND ac.OWNER = {s} AND ac.TABLE_NAME = {t} \
ORDER BY ac.CONSTRAINT_NAME, acc.POSITION",
s = quote_value(schema),
t = quote_value(table),
);
let rows: Vec<MySqlRow> = sqlx::raw_sql(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ForeignKeyInfo {
name: get_str(row, 0),
column: get_str(row, 1),
ref_table: get_str(row, 2),
ref_column: get_str(row, 3),
})
.collect())
}
pub async fn list_triggers(pool: &MySqlPool, schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
let sql = format!(
"SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE \
FROM ALL_TRIGGERS \
WHERE TABLE_OWNER = {s} AND TABLE_NAME = {t} \
ORDER BY TRIGGER_NAME",
s = quote_value(schema),
t = quote_value(table),
);
let rows: Vec<MySqlRow> = sqlx::raw_sql(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
let trigger_type = get_str(row, 2);
let timing = if trigger_type.contains("BEFORE") {
"BEFORE"
} else if trigger_type.contains("AFTER") {
"AFTER"
} else {
"INSTEAD OF"
};
TriggerInfo { name: get_str(row, 0), event: get_str(row, 1), timing: timing.to_string() }
})
.collect())
}

View File

@ -154,9 +154,9 @@ pub async fn do_execute(
})
.await
}
PoolKind::Mysql(p, bare) => {
PoolKind::Mysql(p, mode) => {
let p = p.clone();
let bare = *bare;
let bare = *mode == crate::connection::MysqlMode::Bare;
drop(connections);
wait_for_query(cancel_token, db::mysql::execute_query(&p, sql, bare)).await.map(truncate_result)
}
@ -396,7 +396,7 @@ pub async fn execute_statements_in_transaction(
let conns = state.connections.lock().await;
conns.get(&pool_key).map(|p| match p {
PoolKind::Postgres(pg) => TxPath::Pg(pg.clone()),
PoolKind::Mysql(mp, bare) => TxPath::Mysql(mp.clone(), *bare),
PoolKind::Mysql(mp, _mode) => TxPath::Mysql(mp.clone(), false),
PoolKind::Sqlite(sq) => TxPath::Sqlite(sq.clone()),
PoolKind::ClickHouse(_) | PoolKind::SqlServer(_) | PoolKind::Dameng(_) | PoolKind::Gaussdb(_) => {
TxPath::Explicit

View File

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::connection::{AppState, PoolKind};
use crate::connection::{AppState, MysqlMode, PoolKind};
use crate::db;
pub fn duckdb_query_tables(con: &duckdb::Connection) -> Result<Vec<db::TableInfo>, String> {
@ -155,7 +155,13 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
let pool = connections.get(connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Mysql(p, _) => db::mysql::list_databases(p).await,
PoolKind::Mysql(p, mode) => {
if *mode == MysqlMode::OceanBaseOracle {
db::ob_oracle::list_databases(p).await
} else {
db::mysql::list_databases(p).await
}
}
PoolKind::Postgres(p) => db::postgres::list_databases(p).await,
PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await,
PoolKind::DuckDb(_) => Ok(vec![db::DatabaseInfo { name: "main".to_string() }]),
@ -244,7 +250,13 @@ pub async fn list_tables_core(
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p, _) => db::mysql::list_tables(p, schema).await,
PoolKind::Mysql(p, mode) => {
if *mode == MysqlMode::OceanBaseOracle {
db::ob_oracle::list_tables(p, schema).await
} else {
db::mysql::list_tables(p, schema).await
}
}
PoolKind::Postgres(p) => db::postgres::list_tables(p, schema).await,
PoolKind::Sqlite(p) => db::sqlite::list_tables(p, schema).await,
_ => Ok(vec![]),
@ -297,7 +309,13 @@ pub async fn get_columns_core(
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p, _) => db::mysql::get_columns(p, database, table).await,
PoolKind::Mysql(p, mode) => {
if *mode == MysqlMode::OceanBaseOracle {
db::ob_oracle::get_columns(p, database, table).await
} else {
db::mysql::get_columns(p, database, table).await
}
}
PoolKind::Postgres(p) => db::postgres::get_columns(p, schema, table).await,
PoolKind::Sqlite(p) => db::sqlite::get_columns(p, schema, table).await,
_ => Ok(vec![]),
@ -341,7 +359,13 @@ pub async fn list_indexes_core(
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p, _) => db::mysql::list_indexes(p, schema, table).await,
PoolKind::Mysql(p, mode) => {
if *mode == MysqlMode::OceanBaseOracle {
db::ob_oracle::list_indexes(p, schema, table).await
} else {
db::mysql::list_indexes(p, schema, table).await
}
}
PoolKind::Postgres(p) => db::postgres::list_indexes(p, schema, table).await,
PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, schema, table).await,
_ => Ok(vec![]),
@ -385,7 +409,13 @@ pub async fn list_foreign_keys_core(
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p, _) => db::mysql::list_foreign_keys(p, schema, table).await,
PoolKind::Mysql(p, mode) => {
if *mode == MysqlMode::OceanBaseOracle {
db::ob_oracle::list_foreign_keys(p, schema, table).await
} else {
db::mysql::list_foreign_keys(p, schema, table).await
}
}
PoolKind::Postgres(p) => db::postgres::list_foreign_keys(p, schema, table).await,
PoolKind::Sqlite(p) => db::sqlite::list_foreign_keys(p, schema, table).await,
_ => Ok(vec![]),
@ -429,7 +459,13 @@ pub async fn list_triggers_core(
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p, _) => db::mysql::list_triggers(p, schema, table).await,
PoolKind::Mysql(p, mode) => {
if *mode == MysqlMode::OceanBaseOracle {
db::ob_oracle::list_triggers(p, schema, table).await
} else {
db::mysql::list_triggers(p, schema, table).await
}
}
PoolKind::Postgres(p) => db::postgres::list_triggers(p, schema, table).await,
PoolKind::Sqlite(p) => db::sqlite::list_triggers(p, schema, table).await,
_ => Ok(vec![]),

View File

@ -473,9 +473,9 @@ pub async fn execute_on_pool(state: &AppState, pool_key: &str, sql: &str) -> Res
let pool = connections.get(pool_key).ok_or("Connection not found")?;
match pool {
PoolKind::Mysql(p, bare) => {
PoolKind::Mysql(p, mode) => {
let p = p.clone();
let bare = *bare;
let bare = *mode == crate::connection::MysqlMode::Bare;
drop(connections);
db::mysql::execute_query(&p, sql, bare).await
}

View File

@ -3,7 +3,7 @@ use tauri::State;
pub use dbx_core::connection::{
connection_url_for_endpoint, expand_tilde, probe_connection_endpoint, redacted_connection_url_for_endpoint,
AppState, PoolKind,
AppState, MysqlMode, PoolKind,
};
use dbx_core::db;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
@ -149,9 +149,13 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
let url = connection_url_for_endpoint(&config, &host, port);
let pool = match config.db_type {
DatabaseType::Mysql if config.needs_bare_mysql() => PoolKind::Mysql(db::mysql::connect_bare(&url).await?, true),
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?, false),
DatabaseType::Doris | DatabaseType::StarRocks => PoolKind::Mysql(db::mysql::connect_bare(&url).await?, true),
DatabaseType::Mysql if config.needs_bare_mysql() => {
PoolKind::Mysql(db::mysql::connect_bare(&url).await?, MysqlMode::Bare)
}
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?, MysqlMode::Normal),
DatabaseType::Doris | DatabaseType::StarRocks => {
PoolKind::Mysql(db::mysql::connect_bare(&url).await?, MysqlMode::Bare)
}
DatabaseType::Postgres | DatabaseType::Redshift => PoolKind::Postgres(db::postgres::connect(&url).await?),
DatabaseType::Sqlite => PoolKind::Sqlite(db::sqlite::connect_path(&expand_tilde(&config.host)).await?),
DatabaseType::Redis => {