feat: add DM (Dameng) database support via ODBC

Connect to DM8 databases using the odbc-api crate with unixODBC.
Includes schema browsing, query execution, DDL generation,
and full frontend integration with download link to DM ODBC driver.
This commit is contained in:
t8y2 2026-05-07 01:02:56 +08:00
parent 02813f4b9e
commit 4bf4f3b77f
24 changed files with 1111 additions and 122 deletions

View File

@ -43,7 +43,7 @@ jobs:
- name: Install system dependencies (Linux)
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev unixodbc-dev
- name: Cargo fmt check
run: cargo fmt --check

View File

@ -59,6 +59,10 @@ jobs:
shared-key: release-${{ matrix.target }}-${{ steps.deps-hash.outputs.hash }}
cache-on-failure: true
- name: Install system dependencies (macOS)
if: startsWith(matrix.platform, 'macos')
run: brew install unixodbc
- name: Install Apple certificate (macOS)
if: startsWith(matrix.platform, 'macos')
env:
@ -79,7 +83,7 @@ jobs:
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev unixodbc-dev
- name: Setup Tauri signing key
shell: bash

630
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@ ARG TARGETARCH
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake pkg-config perl python3-pip gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu \
unixodbc-dev libodbc2:amd64 libodbc2:arm64 \
&& pip3 install --break-system-packages ziglang \
&& cargo install cargo-zigbuild \
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu \
@ -59,7 +60,7 @@ RUN case "$TARGETARCH" in \
# Stage 3: Final image
FROM debian:bookworm-slim
ARG TARGETPLATFORM
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 unixodbc && rm -rf /var/lib/apt/lists/*
COPY --from=backend /out/${TARGETPLATFORM}/dbx-web /usr/local/bin/
COPY --from=frontend /app/dist /app/static
ENV DBX_STATIC_DIR=/app/static

View File

@ -27,3 +27,4 @@ russh = "0.60"
portpicker = "0.1.1"
csv = "1"
calamine = "0.30.1"
odbc-api = "25"

View File

@ -28,6 +28,7 @@ pub enum PoolKind {
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
Oracle(Arc<tokio::sync::Mutex<db::oracle_driver::OracleClient>>),
Elasticsearch(db::elasticsearch_driver::EsClient),
Dameng(Arc<std::sync::Mutex<db::dm_driver::DmClient>>),
}
pub struct AppState {
@ -60,7 +61,7 @@ impl AppState {
return Ok(connection_id.to_string());
}
let is_single_conn = matches!(db_type, Some(DatabaseType::Oracle));
let is_single_conn = matches!(db_type, Some(DatabaseType::Oracle) | Some(DatabaseType::Dameng));
let pool_key = if is_single_conn {
connection_id.to_string()
} else {
@ -95,7 +96,7 @@ impl AppState {
let mut db_config = config.clone();
if let Some(db) = database {
if db_config.db_type != DatabaseType::Oracle {
if db_config.db_type != DatabaseType::Oracle && db_config.db_type != DatabaseType::Dameng {
db_config.database = Some(db.to_string());
}
}
@ -162,6 +163,17 @@ impl AppState {
db::elasticsearch_driver::test_connection(&client).await?;
PoolKind::Elasticsearch(client)
}
DatabaseType::Dameng => {
let client = db::dm_driver::connect(
&host,
port,
db_config.database.as_deref().unwrap_or(""),
&db_config.username,
&db_config.password,
)
.await?;
PoolKind::Dameng(Arc::new(std::sync::Mutex::new(client)))
}
};
self.connections.lock().await.insert(pool_key.clone(), pool);
@ -205,7 +217,11 @@ impl AppState {
let configs = self.configs.lock().await;
configs
.get(connection_id)
.map(|c| c.db_type == DatabaseType::Oracle || c.db_type == DatabaseType::Elasticsearch)
.map(|c| {
c.db_type == DatabaseType::Oracle
|| c.db_type == DatabaseType::Elasticsearch
|| c.db_type == DatabaseType::Dameng
})
.unwrap_or(false)
};
let pool_key = if is_single_conn {

View File

@ -0,0 +1,334 @@
use std::time::Instant;
use odbc_api::{buffers::TextRowSet, ConnectionOptions, Cursor, Environment, ResultSetMetadata};
use crate::types::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
use super::CONNECTION_TIMEOUT_SECS;
static ENV: std::sync::LazyLock<Environment> =
std::sync::LazyLock::new(|| Environment::new().expect("Failed to initialize ODBC environment"));
pub struct DmClient {
conn: odbc_api::Connection<'static>,
}
unsafe impl Send for DmClient {}
impl DmClient {
pub fn query_rows(&self, sql: &str) -> Result<Vec<Vec<String>>, String> {
match self.conn.execute(sql, (), None).map_err(|e| e.to_string())? {
Some(cursor) => read_cursor(cursor),
None => Ok(vec![]),
}
}
fn query_single_column(&self, sql: &str) -> Result<Vec<String>, String> {
Ok(self.query_rows(sql)?.into_iter().filter_map(|r| r.into_iter().next()).collect())
}
}
fn read_cursor(cursor: impl Cursor) -> Result<Vec<Vec<String>>, String> {
let mut cursor = cursor;
let col_count = cursor.num_result_cols().map_err(|e| e.to_string())? as u16;
let buffer = TextRowSet::for_cursor(1000, &mut cursor, Some(8192)).map_err(|e| e.to_string())?;
let mut row_cursor = cursor.bind_buffer(buffer).map_err(|e| e.to_string())?;
let mut rows = Vec::new();
while let Some(batch) = row_cursor.fetch().map_err(|e| e.to_string())? {
for row_idx in 0..batch.num_rows() {
let vals: Vec<String> = (0..col_count as usize)
.map(|col| {
batch.at(col, row_idx).and_then(|bytes| std::str::from_utf8(bytes).ok()).unwrap_or("").to_string()
})
.collect();
rows.push(vals);
}
}
Ok(rows)
}
pub async fn connect(host: &str, port: u16, database: &str, user: &str, pass: &str) -> Result<DmClient, String> {
let conn_str = format!(
"Driver={{DM8 ODBC DRIVER}};Server={host};TCP_PORT={port};DATABASE={db};UID={user};PWD={pass}",
host = host,
port = port,
db = database,
user = user,
pass = pass,
);
let result = tokio::time::timeout(
std::time::Duration::from_secs(CONNECTION_TIMEOUT_SECS),
tokio::task::spawn_blocking(move || {
ENV.connect_with_connection_string(&conn_str, ConnectionOptions::default())
.map_err(|e| {
let msg = e.to_string();
if msg.contains("Data source name not found") || msg.contains("Driver") {
format!(
"DM8 ODBC driver not found. Please install the DM8 ODBC driver \
and register it in odbcinst.ini (Linux/macOS) or the ODBC Data Source Administrator (Windows). \
Original error: {msg}"
)
} else {
format!("DM connection failed: {msg}")
}
})
.map(|conn| DmClient { conn })
}),
)
.await
.map_err(|_| format!("DM connection timed out ({CONNECTION_TIMEOUT_SECS}s)"))?
.map_err(|e| format!("DM connection task failed: {e}"))?;
result
}
pub fn list_databases(client: &DmClient) -> Result<Vec<DatabaseInfo>, String> {
let rows = client.query_single_column("SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME")?;
Ok(rows.into_iter().map(|name| DatabaseInfo { name }).collect())
}
pub fn list_schemas(client: &DmClient) -> Result<Vec<String>, String> {
client.query_single_column("SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME")
}
pub fn list_tables(client: &DmClient, schema: &str) -> Result<Vec<TableInfo>, String> {
let s = schema.replace('\'', "''");
let sql = format!(
"SELECT TABLE_NAME, 'TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = '{s}' \
UNION ALL \
SELECT VIEW_NAME, 'VIEW' FROM ALL_VIEWS WHERE OWNER = '{s}' \
ORDER BY 1"
);
let rows = client.query_rows(&sql)?;
Ok(rows
.into_iter()
.map(|r| TableInfo {
name: r.first().cloned().unwrap_or_default(),
table_type: r.get(1).cloned().unwrap_or_else(|| "TABLE".to_string()),
})
.collect())
}
pub fn get_columns(client: &DmClient, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let s = schema.replace('\'', "''");
let t = table.replace('\'', "''");
let pk_rows = client.query_single_column(&format!(
"SELECT cols.COLUMN_NAME FROM ALL_CONS_COLUMNS cols \
JOIN ALL_CONSTRAINTS cons ON cols.CONSTRAINT_NAME = cons.CONSTRAINT_NAME AND cols.OWNER = cons.OWNER \
WHERE cons.CONSTRAINT_TYPE = 'P' AND cons.OWNER = '{s}' AND cons.TABLE_NAME = '{t}'"
))?;
let pk_names: std::collections::HashSet<String> = pk_rows.into_iter().collect();
let col_rows = client.query_rows(&format!(
"SELECT COLUMN_NAME, DATA_TYPE, NULLABLE, DATA_PRECISION, DATA_SCALE, DATA_LENGTH, CHAR_LENGTH \
FROM ALL_TAB_COLUMNS \
WHERE OWNER = '{s}' AND TABLE_NAME = '{t}' \
ORDER BY COLUMN_ID"
))?;
Ok(col_rows
.into_iter()
.map(|r| {
let name = r.first().cloned().unwrap_or_default();
let base = r.get(1).cloned().unwrap_or_default();
let num_prec = r.get(3).and_then(|v| v.parse::<i32>().ok());
let num_scale = r.get(4).and_then(|v| v.parse::<i32>().ok());
let data_len = r.get(5).and_then(|v| v.parse::<i32>().ok());
let char_len = r.get(6).and_then(|v| v.parse::<i32>().ok());
let data_type = match base.to_uppercase().as_str() {
"VARCHAR2" | "NVARCHAR2" | "VARCHAR" | "CHAR" | "NCHAR" => {
let len = char_len.or(data_len);
match len {
Some(n) => format!("{base}({n})"),
None => base,
}
}
"NUMBER" | "NUMERIC" | "DECIMAL" => match (num_prec, num_scale) {
(Some(p), Some(s)) if s > 0 => format!("{base}({p},{s})"),
(Some(p), _) if p > 0 => format!("{base}({p})"),
_ => base,
},
"RAW" => match data_len {
Some(n) => format!("RAW({n})"),
None => "RAW".to_string(),
},
_ => base,
};
ColumnInfo {
is_primary_key: pk_names.contains(&name),
name,
data_type,
is_nullable: r.get(2).map(|v| v == "Y").unwrap_or(false),
column_default: None,
extra: None,
comment: None,
numeric_precision: num_prec,
numeric_scale: num_scale,
character_maximum_length: char_len,
}
})
.collect())
}
pub fn list_indexes(client: &DmClient, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let s = schema.replace('\'', "''");
let t = table.replace('\'', "''");
let sql = format!(
"SELECT i.INDEX_NAME, \
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS, \
i.UNIQUENESS, \
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK, \
i.INDEX_TYPE \
FROM ALL_INDEXES i \
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER \
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER \
AND c.CONSTRAINT_TYPE = 'P' \
WHERE i.TABLE_OWNER = '{s}' AND i.TABLE_NAME = '{t}' \
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE \
ORDER BY i.INDEX_NAME"
);
let rows = client.query_rows(&sql)?;
Ok(rows
.into_iter()
.map(|r| {
let cols_str = r.get(1).cloned().unwrap_or_default();
IndexInfo {
name: r.first().cloned().unwrap_or_default(),
columns: cols_str.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(),
is_unique: r.get(2).map(|v| v == "UNIQUE").unwrap_or(false),
is_primary: r.get(3).map(|v| v == "1").unwrap_or(false),
filter: None,
index_type: r.get(4).cloned(),
included_columns: None,
comment: None,
}
})
.collect())
}
pub fn list_foreign_keys(client: &DmClient, schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let s = schema.replace('\'', "''");
let t = table.replace('\'', "''");
let sql = format!(
"SELECT c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME \
FROM ALL_CONSTRAINTS c \
JOIN ALL_CONS_COLUMNS cc ON c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME AND c.OWNER = cc.OWNER \
JOIN ALL_CONSTRAINTS rc ON c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND c.R_OWNER = rc.OWNER \
JOIN ALL_CONS_COLUMNS rcc ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME AND rc.OWNER = rcc.OWNER \
WHERE c.CONSTRAINT_TYPE = 'R' AND c.OWNER = '{s}' AND c.TABLE_NAME = '{t}' \
ORDER BY c.CONSTRAINT_NAME"
);
let rows = client.query_rows(&sql)?;
Ok(rows
.into_iter()
.map(|r| ForeignKeyInfo {
name: r.first().cloned().unwrap_or_default(),
column: r.get(1).cloned().unwrap_or_default(),
ref_table: r.get(2).cloned().unwrap_or_default(),
ref_column: r.get(3).cloned().unwrap_or_default(),
})
.collect())
}
pub fn list_triggers(client: &DmClient, schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
let s = schema.replace('\'', "''");
let t = table.replace('\'', "''");
let sql = format!(
"SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE \
FROM ALL_TRIGGERS \
WHERE OWNER = '{s}' AND TABLE_NAME = '{t}' \
ORDER BY TRIGGER_NAME"
);
let rows = client.query_rows(&sql)?;
Ok(rows
.into_iter()
.map(|r| TriggerInfo {
name: r.first().cloned().unwrap_or_default(),
event: r.get(1).cloned().unwrap_or_default(),
timing: r.get(2).cloned().unwrap_or_default(),
})
.collect())
}
pub fn execute_query_sync(client: &DmClient, sql: &str) -> Result<QueryResult, String> {
let start = Instant::now();
let sql = sql.trim().trim_end_matches(';');
let trimmed = sql.to_uppercase();
if trimmed.starts_with("SELECT")
|| trimmed.starts_with("WITH")
|| trimmed.starts_with("SHOW")
|| trimmed.starts_with("DESCRIBE")
|| trimmed.starts_with("EXPLAIN")
{
match client.conn.execute(sql, (), None).map_err(|e| e.to_string())? {
Some(mut cursor) => {
let col_count = cursor.num_result_cols().map_err(|e| e.to_string())? as u16;
let columns: Vec<String> = (1..=col_count)
.map(|i| cursor.col_name(i).map_err(|e| e.to_string()).unwrap_or_else(|_| format!("col{i}")))
.collect();
let buffer = TextRowSet::for_cursor(1000, &mut cursor, Some(8192)).map_err(|e| e.to_string())?;
let mut row_cursor = cursor.bind_buffer(buffer).map_err(|e| e.to_string())?;
let mut rows = Vec::new();
while let Some(batch) = row_cursor.fetch().map_err(|e| e.to_string())? {
for row_idx in 0..batch.num_rows() {
let vals: Vec<serde_json::Value> = (0..col_count as usize)
.map(|col| {
batch
.at(col, row_idx)
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.map(|s| serde_json::Value::String(s.to_string()))
.unwrap_or(serde_json::Value::Null)
})
.collect();
rows.push(vals);
if rows.len() >= crate::query::MAX_ROWS {
break;
}
}
if rows.len() >= crate::query::MAX_ROWS {
break;
}
}
let truncated = rows.len() >= crate::query::MAX_ROWS;
Ok(QueryResult {
columns,
rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated,
})
}
None => Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
}),
}
} else {
match client.conn.execute(sql, (), None) {
Ok(Some(_cursor)) => Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
}),
Ok(None) => Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
}),
Err(e) => Err(e.to_string()),
}
}
}

View File

@ -1,4 +1,5 @@
pub mod clickhouse_driver;
pub mod dm_driver;
pub mod duckdb_driver;
pub mod elasticsearch_driver;
pub mod file_validator;

View File

@ -70,6 +70,7 @@ pub enum DatabaseType {
#[serde(rename = "starrocks")]
StarRocks,
Redshift,
Dameng,
}
impl ConnectionConfig {
@ -128,6 +129,7 @@ impl ConnectionConfig {
}
DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"),
DatabaseType::Elasticsearch => format!("http://{host}:{port}"),
DatabaseType::Dameng => format!("dm://{host}:{port}{db_part}"),
}
}
@ -186,6 +188,9 @@ impl ConnectionConfig {
format!("oracle://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Elasticsearch => format!("http://{host}:{port}"),
DatabaseType::Dameng => {
format!("dm://{}:{}@{host}:{port}{db_part}", username, password)
}
}
}

View File

@ -214,6 +214,20 @@ pub async fn do_execute(
PoolKind::Elasticsearch(_) => Err("Use document browser for Elasticsearch".to_string()),
PoolKind::Redis(_) => Err("Use Redis-specific commands".to_string()),
PoolKind::MongoDb(_) => Err("Use MongoDB-specific commands".to_string()),
PoolKind::Dameng(client) => {
let client = client.clone();
let sql = sql.to_string();
drop(connections);
wait_for_query(cancel_token, async move {
let task = tokio::task::spawn_blocking(move || {
let client = client.lock().map_err(|e| e.to_string())?;
db::dm_driver::execute_query_sync(&client, &sql)
});
task.await.map_err(|e| e.to_string())?
})
.await
.map(truncate_result)
}
}
}

View File

@ -100,6 +100,16 @@ pub fn extract_oracle(
}
}
pub fn extract_dameng(
connections: &HashMap<String, PoolKind>,
key: &str,
) -> Option<Arc<std::sync::Mutex<db::dm_driver::DmClient>>> {
match connections.get(key)? {
PoolKind::Dameng(client) => Some(client.clone()),
_ => None,
}
}
pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Result<Vec<db::DatabaseInfo>, String> {
{
let connections = state.connections.lock().await;
@ -117,6 +127,11 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
let client = client.lock().await;
return db::oracle_driver::list_databases(&*client).await;
}
if let Some(client) = extract_dameng(&connections, connection_id) {
drop(connections);
let client = client.lock().map_err(|e| e.to_string())?;
return db::dm_driver::list_databases(&client);
}
}
let connections = state.connections.lock().await;
@ -146,6 +161,11 @@ pub async fn list_schemas_core(state: &AppState, connection_id: &str, database:
let client = client.lock().await;
return db::oracle_driver::list_schemas(&*client).await;
}
if let Some(client) = extract_dameng(&connections, &pool_key) {
drop(connections);
let client = client.lock().map_err(|e| e.to_string())?;
return db::dm_driver::list_schemas(&client);
}
}
let connections = state.connections.lock().await;
@ -186,6 +206,11 @@ pub async fn list_tables_core(
let client = client.lock().await;
return db::oracle_driver::list_tables(&*client, schema).await;
}
if let Some(client) = extract_dameng(&connections, &pool_key) {
drop(connections);
let client = client.lock().map_err(|e| e.to_string())?;
return db::dm_driver::list_tables(&client, schema);
}
}
let connections = state.connections.lock().await;
@ -229,6 +254,11 @@ pub async fn get_columns_core(
let client = client.lock().await;
return db::oracle_driver::get_columns(&*client, schema, table).await;
}
if let Some(client) = extract_dameng(&connections, &pool_key) {
drop(connections);
let client = client.lock().map_err(|e| e.to_string())?;
return db::dm_driver::get_columns(&client, schema, table);
}
}
let connections = state.connections.lock().await;
@ -263,6 +293,11 @@ pub async fn list_indexes_core(
let client = client.lock().await;
return db::oracle_driver::list_indexes(&*client, schema, table).await;
}
if let Some(client) = extract_dameng(&connections, &pool_key) {
drop(connections);
let client = client.lock().map_err(|e| e.to_string())?;
return db::dm_driver::list_indexes(&client, schema, table);
}
}
let connections = state.connections.lock().await;
@ -297,6 +332,11 @@ pub async fn list_foreign_keys_core(
let client = client.lock().await;
return db::oracle_driver::list_foreign_keys(&*client, schema, table).await;
}
if let Some(client) = extract_dameng(&connections, &pool_key) {
drop(connections);
let client = client.lock().map_err(|e| e.to_string())?;
return db::dm_driver::list_foreign_keys(&client, schema, table);
}
}
let connections = state.connections.lock().await;
@ -331,6 +371,11 @@ pub async fn list_triggers_core(
let client = client.lock().await;
return db::oracle_driver::list_triggers(&*client, schema, table).await;
}
if let Some(client) = extract_dameng(&connections, &pool_key) {
drop(connections);
let client = client.lock().map_err(|e| e.to_string())?;
return db::dm_driver::list_triggers(&client, schema, table);
}
}
let connections = state.connections.lock().await;
@ -391,6 +436,11 @@ pub async fn get_table_ddl_core(
let client = client.lock().await;
return build_oracle_ddl(&*client, schema, table).await;
}
if let Some(client) = extract_dameng(&connections, &pool_key) {
drop(connections);
let client = client.lock().map_err(|e| e.to_string())?;
return build_dameng_ddl(&client, schema, table);
}
}
let connections = state.connections.lock().await;
@ -598,3 +648,50 @@ pub async fn build_oracle_ddl(
}
Ok(ddl)
}
pub fn build_dameng_ddl(client: &db::dm_driver::DmClient, schema: &str, table: &str) -> Result<String, String> {
let columns = db::dm_driver::get_columns(client, schema, table)?;
let indexes = db::dm_driver::list_indexes(client, schema, table)?;
let fkeys = db::dm_driver::list_foreign_keys(client, schema, table)?;
let mut ddl = format!("CREATE TABLE \"{schema}\".\"{table}\" (\n");
let col_lines: Vec<String> = columns
.iter()
.map(|c| {
let mut line = format!(" \"{}\" {}", c.name, c.data_type);
if !c.is_nullable {
line.push_str(" NOT NULL");
}
if let Some(ref def) = c.column_default {
line.push_str(&format!(" DEFAULT {def}"));
}
line
})
.collect();
ddl.push_str(&col_lines.join(",\n"));
let pks: Vec<&str> = columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.as_str()).collect();
if !pks.is_empty() {
ddl.push_str(&format!(
",\n PRIMARY KEY ({})",
pks.iter().map(|k| format!("\"{k}\"")).collect::<Vec<_>>().join(", ")
));
}
for fk in &fkeys {
ddl.push_str(&format!(
",\n CONSTRAINT \"{}\" FOREIGN KEY (\"{}\") REFERENCES \"{}\"(\"{}\")",
fk.name, fk.column, fk.ref_table, fk.ref_column
));
}
ddl.push_str("\n);\n");
for idx in &indexes {
if idx.is_primary {
continue;
}
let unique = if idx.is_unique { "UNIQUE " } else { "" };
let cols = idx.columns.iter().map(|c| format!("\"{c}\"")).collect::<Vec<_>>().join(", ");
ddl.push_str(&format!("\nCREATE {unique}INDEX \"{}\" ON \"{schema}\".\"{table}\" ({cols});", idx.name));
}
Ok(ddl)
}

View File

@ -112,6 +112,15 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
db::elasticsearch_driver::EsClient::new(&url, Some(&config.username), Some(&config.password));
db::elasticsearch_driver::test_connection(&client).await.map(|_| "Connection successful".to_string())
}
DatabaseType::Dameng => db::dm_driver::connect(
&host,
port,
config.database.as_deref().unwrap_or(""),
&config.username,
&config.password,
)
.await
.map(|_| "Connection successful".to_string()),
},
};
@ -179,6 +188,17 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
db::elasticsearch_driver::test_connection(&client).await?;
PoolKind::Elasticsearch(client)
}
DatabaseType::Dameng => {
let client = db::dm_driver::connect(
&host,
port,
config.database.as_deref().unwrap_or(""),
&config.username,
&config.password,
)
.await?;
PoolKind::Dameng(std::sync::Arc::new(std::sync::Mutex::new(client)))
}
};
state.connections.lock().await.insert(id.clone(), pool);
@ -205,6 +225,7 @@ pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: Strin
PoolKind::SqlServer(_) => {}
PoolKind::Oracle(_) => {}
PoolKind::Elasticsearch(_) => {}
PoolKind::Dameng(_) => {}
}
}
}

View File

@ -166,7 +166,7 @@ const driverProfiles: Record<
label: "CockroachDB",
icon: "cockroachdb",
},
dm: { type: "postgres", port: 5236, user: "SYSDBA", label: "DM (Dameng)", icon: "dm" },
dm: { type: "dameng", port: 5236, user: "SYSDBA", label: "DM (Dameng)", icon: "dm" },
tdengine: { type: "mysql", port: 6030, user: "root", label: "TDengine", icon: "tdengine" },
custom_mysql: {
type: "mysql",
@ -322,6 +322,7 @@ const dbOptions = [
{ value: "oracle", label: "Oracle" },
{ value: "elasticsearch", label: "Elasticsearch" },
{ value: "mariadb", label: "MariaDB" },
{ value: "dm", label: "DM (Dameng)" },
];
const mysqlCompat = [
@ -340,7 +341,6 @@ const pgCompat = [
{ value: "gaussdb", label: "GaussDB" },
{ value: "kingbase", label: "KingBase" },
{ value: "vastbase", label: "Vastbase" },
{ value: "dm", label: "DM (Dameng)" },
{ value: "redshift", label: "Redshift" },
{ value: "cockroachdb", label: "CockroachDB" },
{ value: "custom_postgres", label: "Custom" },
@ -844,6 +844,13 @@ async function browseDbFilePath() {
<span />
<p class="col-span-3 text-xs text-muted-foreground">
{{ t("connection.dmCompatHint") }}
<a
href="https://eco.dameng.com/download/"
target="_blank"
class="underline text-primary hover:text-primary/80"
>
{{ t("connection.dmDownload") }}
</a>
</p>
</div>

View File

@ -49,8 +49,8 @@ const props = defineProps<{
focusTableName?: string;
}>();
const SQL_TYPES: DatabaseType[] = ["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"];
const SCHEMA_AWARE_TYPES: DatabaseType[] = ["postgres", "sqlserver", "oracle", "redshift"];
const SQL_TYPES: DatabaseType[] = ["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift", "dameng"];
const SCHEMA_AWARE_TYPES: DatabaseType[] = ["postgres", "sqlserver", "oracle", "redshift", "dameng"];
const CARD_WIDTH = 270;
const COLUMN_ROW_HEIGHT = 24;
const CARD_HEADER_HEIGHT = 44;

View File

@ -76,7 +76,8 @@ async function resolveSchema(connectionId: string, database: string): Promise<st
config?.db_type === "postgres" ||
config?.db_type === "sqlserver" ||
config?.db_type === "oracle" ||
config?.db_type === "redshift";
config?.db_type === "redshift" ||
config?.db_type === "dameng";
if (needsSchema) {
const schemas = await api.listSchemas(connectionId, database);
return schemas.includes("public") ? "public" : (schemas[0] ?? "");

View File

@ -54,7 +54,7 @@ type SearchTableTask = {
table: TableInfo;
};
const SCHEMA_AWARE_TYPES = new Set<DatabaseType>(["postgres", "sqlserver", "oracle", "redshift"]);
const SCHEMA_AWARE_TYPES = new Set<DatabaseType>(["postgres", "sqlserver", "oracle", "redshift", "dameng"]);
const SYSTEM_SCHEMAS = new Set([
"information_schema",
"pg_catalog",

View File

@ -87,7 +87,7 @@ const emit = defineEmits<{
}>();
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift", "dameng"]);
const databaseSearchSupportedTypes = new Set([
"mysql",
"postgres",
@ -97,6 +97,7 @@ const databaseSearchSupportedTypes = new Set([
"redshift",
"duckdb",
"clickhouse",
"dameng",
]);
const tableImportSupportedTypes = new Set([
"mysql",
@ -109,9 +110,18 @@ const tableImportSupportedTypes = new Set([
"doris",
"starrocks",
"redshift",
"dameng",
]);
const tableStructureSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver"]);
const fieldLineageSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
const fieldLineageSupportedTypes = new Set([
"mysql",
"postgres",
"sqlite",
"sqlserver",
"oracle",
"redshift",
"dameng",
]);
const isExportingDatabase = ref(false);
function currentDatabaseType(): DatabaseType | undefined {
@ -123,7 +133,7 @@ function quoteIdent(name: string): string {
}
function isSchemaAwareDbType(dbType?: DatabaseType): boolean {
return dbType === "postgres" || dbType === "oracle" || dbType === "sqlserver";
return dbType === "postgres" || dbType === "oracle" || dbType === "sqlserver" || dbType === "dameng";
}
function qualifiedTableName(tableName: string, schema?: string): string {
@ -277,7 +287,11 @@ async function openData() {
if (!config) throw new Error("Connection config not found");
const qualifiedName =
(config.db_type === "postgres" || config.db_type === "oracle" || config.db_type === "sqlserver") && node.schema
(config.db_type === "postgres" ||
config.db_type === "oracle" ||
config.db_type === "sqlserver" ||
config.db_type === "dameng") &&
node.schema
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
: quoteIdent(node.label);
@ -286,7 +300,7 @@ async function openData() {
const pks = columns.filter((c) => c.is_primary_key).map((c) => c.name);
const order = pks.length ? ` ORDER BY ${pks.map((pk) => `${quoteIdent(pk)} ASC`).join(", ")}` : "";
let sql: string;
if (config.db_type === "oracle") {
if (config.db_type === "oracle" || config.db_type === "dameng") {
sql = `SELECT * FROM ${qualifiedName}${order} FETCH FIRST 100 ROWS ONLY`;
} else if (config.db_type === "sqlserver") {
sql = `SELECT TOP 100 * FROM ${qualifiedName}${order}`;
@ -462,7 +476,7 @@ async function confirmDuplicateStructure() {
sql = `CREATE TABLE ${target} (LIKE ${source} INCLUDING ALL);`;
} else if (dbType === "sqlserver") {
sql = `SELECT TOP 0 * INTO ${target} FROM ${source};`;
} else if (dbType === "oracle") {
} else if (dbType === "oracle" || dbType === "dameng") {
sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 1=0`;
} else {
sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 0;`;
@ -653,7 +667,11 @@ async function exportData(format: "csv" | "json" | "sql") {
try {
await connectionStore.ensureConnected(node.connectionId);
const qualifiedName =
(config.db_type === "postgres" || config.db_type === "oracle" || config.db_type === "sqlserver") && node.schema
(config.db_type === "postgres" ||
config.db_type === "oracle" ||
config.db_type === "sqlserver" ||
config.db_type === "dameng") &&
node.schema
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
: quoteIdent(node.label);
const result = await api.executeQuery(node.connectionId, node.database, `SELECT * FROM ${qualifiedName}`);

View File

@ -25,7 +25,16 @@ const props = defineProps<{
const store = useConnectionStore();
const SQL_TYPES: DatabaseType[] = ["mysql", "postgres", "sqlite", "sqlserver", "oracle", "clickhouse", "duckdb"];
const SQL_TYPES: DatabaseType[] = [
"mysql",
"postgres",
"sqlite",
"sqlserver",
"oracle",
"clickhouse",
"duckdb",
"dameng",
];
const sqlConnections = computed(() => store.connections.filter((c) => SQL_TYPES.includes(c.db_type)));
@ -122,7 +131,10 @@ async function loadTables() {
try {
const config = store.getConfig(sourceConnectionId.value);
const needsSchema =
config?.db_type === "postgres" || config?.db_type === "sqlserver" || config?.db_type === "oracle";
config?.db_type === "postgres" ||
config?.db_type === "sqlserver" ||
config?.db_type === "oracle" ||
config?.db_type === "dameng";
if (needsSchema) {
const schemas = await api.listSchemas(sourceConnectionId.value, sourceDatabase.value);
sourceSchema.value = schemas.includes("public") ? "public" : (schemas[0] ?? "");
@ -210,7 +222,10 @@ async function startTransfer() {
// Auto-detect target schema
const targetConfig = store.getConfig(targetConnectionId.value);
const targetNeedsSchema =
targetConfig?.db_type === "postgres" || targetConfig?.db_type === "sqlserver" || targetConfig?.db_type === "oracle";
targetConfig?.db_type === "postgres" ||
targetConfig?.db_type === "sqlserver" ||
targetConfig?.db_type === "oracle" ||
targetConfig?.db_type === "dameng";
if (targetNeedsSchema && !targetSchema.value) {
try {
const schemas = await api.listSchemas(targetConnectionId.value, targetDatabase.value);

View File

@ -14,7 +14,7 @@ export function useSchemaOptions() {
function isSchemaAware(connectionId: string): boolean {
const dbType = connectionStore.getConfig(connectionId)?.db_type;
return dbType === "postgres" || dbType === "sqlserver" || dbType === "oracle";
return dbType === "postgres" || dbType === "sqlserver" || dbType === "oracle" || dbType === "dameng";
}
async function loadSchemaOptions(connectionId: string, database: string) {

View File

@ -111,7 +111,8 @@ export default {
sshKeyPassphrasePlaceholder: "Leave empty if key is not encrypted",
sshKeyPathBrowse: "Browse",
sshExposeLan: "Expose tunnel to LAN",
dmCompatHint: "Requires DM PG compatibility mode (set COMPATIBLE_MODE=7 in dm.ini and restart)",
dmCompatHint: "Requires DM8 ODBC driver installed on your system.",
dmDownload: "Download from Dameng",
mongoLegacyHint:
"For older MongoDB servers, enter authSource=admin&authMechanism=SCRAM-SHA-1 here; add directConnection=true only when needed for direct standalone connections.",
compatible: "Compatible",

View File

@ -110,7 +110,8 @@ export default {
sshKeyPassphrasePlaceholder: "密钥未加密则留空",
sshKeyPathBrowse: "浏览",
sshExposeLan: "允许局域网访问隧道",
dmCompatHint: "需要开启达梦 PG 兼容模式dm.ini 中设置 COMPATIBLE_MODE=7 并重启服务)",
dmCompatHint: "需要在系统上安装达梦 DM8 ODBC 驱动程序。",
dmDownload: "前往达梦官网下载",
mongoLegacyHint:
"连接旧版 MongoDB 时,可在此填写 authSource=admin&authMechanism=SCRAM-SHA-1直连单节点时可按需追加 directConnection=true。",
compatible: "兼容",

View File

@ -22,7 +22,13 @@ export function qualifiedTableName(
options: Pick<BuildTableSelectSqlOptions, "databaseType" | "schema" | "tableName">,
): string {
const { databaseType, schema, tableName } = options;
if ((databaseType === "postgres" || databaseType === "oracle" || databaseType === "sqlserver") && schema) {
if (
(databaseType === "postgres" ||
databaseType === "oracle" ||
databaseType === "sqlserver" ||
databaseType === "dameng") &&
schema
) {
return `${quoteTableIdentifier(databaseType, schema)}.${quoteTableIdentifier(databaseType, tableName)}`;
}
return quoteTableIdentifier(databaseType, tableName);
@ -47,7 +53,7 @@ export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string
const orderBy = options.orderBy ?? defaultOrderBy;
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
if (databaseType === "oracle") {
if (databaseType === "oracle" || databaseType === "dameng") {
const offset = options.offset ? ` OFFSET ${options.offset} ROWS` : "";
return `SELECT * FROM ${table}${where}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`;
}

View File

@ -128,6 +128,7 @@ export const useConnectionStore = defineStore("connection", () => {
doris: "Doris",
starrocks: "StarRocks",
redshift: "Redshift",
dameng: "DM (Dameng)",
};
return {
...config,
@ -652,7 +653,7 @@ export const useConnectionStore = defineStore("connection", () => {
function isSchemaAwareDatabase(connectionId: string): boolean {
const dbType = getConfig(connectionId)?.db_type;
return dbType === "postgres" || dbType === "sqlserver" || dbType === "oracle";
return dbType === "postgres" || dbType === "sqlserver" || dbType === "oracle" || dbType === "dameng";
}
async function listCompletionTables(connectionId: string, database: string): Promise<SqlCompletionTable[]> {

View File

@ -11,7 +11,8 @@ export type DatabaseType =
| "elasticsearch"
| "doris"
| "starrocks"
| "redshift";
| "redshift"
| "dameng";
export interface ConnectionConfig {
id: string;