feat(desktop): add connection-level database browser

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-12 02:20:05 +08:00
1 parent 44dbd0e0e7
commit fee3ed1d5a
46 files changed
+781 -29

No files matched your search

+5 -1
View File
@@ -607,7 +607,11 @@ pub async fn test_connection(client: &ChClient, timeout: Duration) -> Result<(),
pub async fn list_databases(client: &ChClient) -> Result<Vec<DatabaseInfo>, String> {
let result = ch_query(client, "SELECT name FROM system.databases ORDER BY name", None).await?;
Ok(result.data.iter().map(|row| DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string() }).collect())
Ok(result
.data
.iter()
.map(|row| DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string(), ..Default::default() })
.collect())
}
pub async fn list_tables(client: &ChClient, database: &str) -> Result<Vec<TableInfo>, String> {
+1 -1
View File
@@ -111,7 +111,7 @@ pub async fn connect(config: &ConnectionConfig, timeout: Duration) -> Result<Clo
}
pub async fn list_databases(_client: &CloudflareD1Client) -> Result<Vec<DatabaseInfo>, String> {
Ok(vec![DatabaseInfo { name: "main".to_string() }])
Ok(vec![DatabaseInfo { name: "main".to_string(), ..Default::default() }])
}
pub async fn list_tables(client: &CloudflareD1Client, _schema: &str) -> Result<Vec<TableInfo>, String> {
+1 -1
View File
@@ -79,7 +79,7 @@ fn database_infos(names: Vec<String>) -> Vec<DatabaseInfo> {
.into_iter()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.map(|name| DatabaseInfo { name })
.map(|name| DatabaseInfo { name, ..Default::default() })
.collect()
}
+1 -1
View File
@@ -226,7 +226,7 @@ pub async fn list_namespaces(client: &HBaseClient) -> Result<Vec<DatabaseInfo>,
let body: RestNamespaces = parse_success_json(response, "list namespaces").await?;
let mut namespaces = body.namespaces;
namespaces.sort_by_key(|name| name.to_ascii_lowercase());
Ok(namespaces.into_iter().map(|name| DatabaseInfo { name }).collect())
Ok(namespaces.into_iter().map(|name| DatabaseInfo { name, ..Default::default() }).collect())
}
pub async fn list_tables(client: &HBaseClient, namespace: &str) -> Result<Vec<TableInfo>, String> {
+2 -2
View File
@@ -347,7 +347,7 @@ pub async fn list_databases(client: &InfluxdbClient) -> Result<Vec<DatabaseInfo>
return Ok(influx_v2_buckets(client, Duration::from_secs(30))
.await?
.into_iter()
.map(|bucket| DatabaseInfo { name: bucket.name })
.map(|bucket| DatabaseInfo { name: bucket.name, ..Default::default() })
.collect());
}
let result = influx_query(client, "SHOW DATABASES", None).await?;
@@ -356,7 +356,7 @@ pub async fn list_databases(client: &InfluxdbClient) -> Result<Vec<DatabaseInfo>
.iter()
.flat_map(|r| &r.series)
.flat_map(|s| &s.values)
.map(|row| DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string() })
.map(|row| DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string(), ..Default::default() })
.collect())
}
+44 -2
View File
@@ -1989,6 +1989,14 @@ pub async fn connect_bare_with_pool_limit_and_setup_database(
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)];
@@ -2003,6 +2011,40 @@ pub async fn list_databases(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, Strin
}
}
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,
@@ -2028,12 +2070,12 @@ pub(super) fn database_infos_from_names(
.filter_map(|name| {
saw_row = true;
let name = name.trim().to_string();
(!name.is_empty()).then_some(DatabaseInfo { name })
(!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() }];
return vec![DatabaseInfo { name: String::new(), ..Default::default() }];
}
databases
}
+1 -1
View File
@@ -53,7 +53,7 @@ pub async fn list_databases(pool: &mysql_async::Pool) -> Result<Vec<DatabaseInfo
let result = conn.query_iter(list_user_schemas_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| DatabaseInfo { name: get_str(row, 0) }).collect())
Ok(rows.iter().map(|row| DatabaseInfo { name: get_str(row, 0), ..Default::default() }).collect())
}
pub async fn list_schemas(pool: &mysql_async::Pool) -> Result<Vec<String>, String> {
+41 -2
View File
@@ -2316,11 +2316,31 @@ fn validate_postgres_ssl_paths(url: &str) -> Result<(), String> {
}
fn list_databases_sql() -> &'static str {
"SELECT datname FROM pg_database \
"SELECT datname \
FROM pg_database \
WHERE datallowconn = true \
ORDER BY datname"
}
fn database_metadata_sql() -> &'static str {
"SELECT d.datname, \
CASE \
WHEN has_database_privilege(d.datname, 'CONNECT') \
OR COALESCE(( \
SELECT pg_has_role(current_user, r.oid, 'MEMBER') \
FROM pg_roles r \
WHERE r.rolname = 'pg_read_all_stats' \
), false) \
THEN pg_database_size(d.oid) \
ELSE NULL \
END AS size_bytes, \
obj_description(d.oid, 'pg_database') AS comment, \
pg_encoding_to_char(d.encoding) AS default_charset \
FROM pg_database d \
WHERE datallowconn = true \
ORDER BY d.datname"
}
fn database_storage_sql() -> &'static str {
"SELECT d.datname, \
CASE \
@@ -2343,7 +2363,26 @@ pub async fn list_databases(pool: &Pool) -> Result<Vec<DatabaseInfo>, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let rows = postgres_query_cached(&client, list_databases_sql(), &[]).await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| DatabaseInfo { name: pg_row_try_string(row, 0) }).collect())
Ok(rows.iter().map(|row| DatabaseInfo { name: pg_row_try_string(row, 0), ..Default::default() }).collect())
}
pub async fn list_database_metadata(pool: &Pool) -> Result<Vec<DatabaseInfo>, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let rows = postgres_query_cached(&client, database_metadata_sql(), &[]).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| DatabaseInfo {
name: pg_row_try_string(row, 0),
size_bytes: row.try_get::<_, Option<i64>>(1).ok().flatten(),
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|value| !value.trim().is_empty()),
default_charset: row
.try_get::<_, Option<String>>(3)
.ok()
.flatten()
.filter(|value| !value.trim().is_empty()),
..Default::default()
})
.collect())
}
pub async fn list_database_storage(pool: &Pool, database_names: &[String]) -> Result<Vec<DatabaseStorageInfo>, String> {
+1 -1
View File
@@ -98,7 +98,7 @@ pub async fn test_connection(client: &RqliteClient, timeout: Duration) -> Result
}
pub async fn list_databases(_client: &RqliteClient) -> Result<Vec<DatabaseInfo>, String> {
Ok(vec![DatabaseInfo { name: "main".to_string() }])
Ok(vec![DatabaseInfo { name: "main".to_string(), ..Default::default() }])
}
pub async fn list_tables(client: &RqliteClient, _schema: &str) -> Result<Vec<TableInfo>, String> {
+1 -1
View File
@@ -1284,7 +1284,7 @@ pub async fn list_databases(pool: &SqliteHandle) -> Result<Vec<DatabaseInfo>, St
let mut stmt = conn.prepare("PRAGMA database_list").map_err(|e| e.to_string())?;
let rows = stmt.query_map([], |row| row.get::<_, String>("name")).map_err(|e| e.to_string())?;
rows.filter_map(|row| match row {
Ok(name) if !name.eq_ignore_ascii_case("temp") => Some(Ok(DatabaseInfo { name })),
Ok(name) if !name.eq_ignore_ascii_case("temp") => Some(Ok(DatabaseInfo { name, ..Default::default() })),
Ok(_) => None,
Err(error) => Some(Err(error)),
})
+42 -2
View File
@@ -1722,7 +1722,47 @@ pub async fn list_databases(client: &mut SqlServerClient) -> Result<Vec<Database
.await
.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| DatabaseInfo { name: row.get::<&str, _>(0).unwrap_or("").to_string() }).collect())
Ok(rows
.iter()
.map(|row| DatabaseInfo { name: row.get::<&str, _>(0).unwrap_or("").to_string(), ..Default::default() })
.collect())
}
pub async fn list_database_metadata(client: &mut SqlServerClient) -> Result<Vec<DatabaseInfo>, String> {
let rows = match list_database_metadata_rows(client).await {
Ok(rows) => rows,
Err(error) => {
log::debug!("Falling back to database names after metadata query failed: {error}");
return list_databases(client).await;
}
};
Ok(rows
.iter()
.map(|row| DatabaseInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
created_at: row.get::<chrono::NaiveDateTime, _>(1).map(|value| value.to_string()),
default_collation: row.get::<&str, _>(2).map(str::to_string).filter(|value| !value.trim().is_empty()),
size_bytes: row.get::<i64, _>(3),
..Default::default()
})
.collect())
}
async fn list_database_metadata_rows(client: &mut SqlServerClient) -> Result<Vec<Row>, String> {
let stream = client
.query(
"SELECT d.name, d.create_date, d.collation_name, \
SUM(COALESCE(CONVERT(bigint, f.size), 0)) * 8192 AS size_bytes \
FROM sys.databases d \
LEFT JOIN sys.master_files f ON f.database_id = d.database_id \
WHERE d.state = 0 \
GROUP BY d.name, d.create_date, d.collation_name \
ORDER BY d.name",
&[],
)
.await;
let stream = stream.map_err(|error| error.to_string())?;
stream.into_first_result().await.map_err(|error| error.to_string())
}
pub async fn get_completion_context(client: &mut SqlServerClient) -> Result<SqlServerCompletionContext, String> {
@@ -1776,7 +1816,7 @@ pub async fn list_linked_server_catalogs(
Ok(rows
.iter()
.filter_map(|row| row.get::<&str, _>(0).map(str::trim).filter(|name| !name.is_empty()))
.map(|name| DatabaseInfo { name: name.to_string() })
.map(|name| DatabaseInfo { name: name.to_string(), ..Default::default() })
.collect())
}
+1 -1
View File
@@ -140,7 +140,7 @@ pub async fn test_connection(client: &TursoClient, timeout: Duration) -> Result<
}
pub async fn list_databases(_client: &TursoClient) -> Result<Vec<DatabaseInfo>, String> {
Ok(vec![DatabaseInfo { name: "main".to_string() }])
Ok(vec![DatabaseInfo { name: "main".to_string(), ..Default::default() }])
}
pub async fn list_tables(client: &TursoClient, _schema: &str) -> Result<Vec<TableInfo>, String> {
@@ -183,7 +183,7 @@ pub async fn test_connection(client: &VictoriaMetricsClient, timeout: Duration)
}
pub async fn list_databases(client: &VictoriaMetricsClient) -> Result<Vec<DatabaseInfo>, String> {
Ok(vec![DatabaseInfo { name: client.database_label.clone() }])
Ok(vec![DatabaseInfo { name: client.database_label.clone(), ..Default::default() }])
}
pub async fn list_tables(client: &VictoriaMetricsClient) -> Result<Vec<TableInfo>, String> {
+41 -2
View File
@@ -174,6 +174,16 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
retry_metadata_connection(state, connection_id, None, || list_databases_once(state, connection_id)).await
}
/// Loads the more expensive database-level properties needed only by the
/// connection resource browser. General metadata paths keep using
/// `list_databases_core`, which only enumerates names.
pub async fn list_database_metadata_core(
state: &AppState,
connection_id: &str,
) -> Result<Vec<db::DatabaseInfo>, String> {
retry_metadata_connection(state, connection_id, None, || list_database_metadata_once(state, connection_id)).await
}
pub async fn list_database_storage_core(
state: &AppState,
connection_id: &str,
@@ -592,7 +602,7 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result<Ve
if is_mongo {
drop(connections);
let dbs = crate::mongo_ops::mongo_list_databases_core(state, connection_id).await?;
return Ok(dbs.into_iter().map(|name| db::DatabaseInfo { name }).collect());
return Ok(dbs.into_iter().map(|name| db::DatabaseInfo { name, ..Default::default() }).collect());
}
drop(connections);
let mut client = client.lock().await;
@@ -632,6 +642,35 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result<Ve
}
}
async fn list_database_metadata_once(state: &AppState, connection_id: &str) -> Result<Vec<db::DatabaseInfo>, String> {
let config = connection_config(state, connection_id).await;
if config.as_ref().is_some_and(|config| db::dolt::is_config(config) || is_doris_family_config(config)) {
return list_databases_once(state, connection_id).await;
}
let connections = state.connections.read().await;
if let Some(client) = extract_pool!(&connections, connection_id, SqlServer) {
drop(connections);
let mut client = client.lock().await;
return db::sqlserver::list_database_metadata(&mut client).await;
}
if let Some(PoolKind::Mysql(pool, mode)) = connections.get(connection_id) {
let pool = pool.clone();
let mode = *mode;
drop(connections);
return if mode == MysqlMode::OceanBaseOracle {
db::ob_oracle::list_databases(&pool).await
} else {
db::mysql::list_database_metadata(&pool).await
};
}
if let Some(pool) = extract_pool!(&connections, connection_id, Postgres) {
drop(connections);
return db::postgres::list_database_metadata(&pool).await;
}
drop(connections);
list_databases_once(state, connection_id).await
}
pub async fn list_schemas_core(state: &AppState, connection_id: &str, database: &str) -> Result<Vec<String>, String> {
list_schemas_core_with_visible_filter(state, connection_id, database, false).await
}
@@ -3347,7 +3386,7 @@ for line in sys.stdin:
}
fn test_database_info(name: &str) -> super::db::DatabaseInfo {
super::db::DatabaseInfo { name: name.to_string() }
super::db::DatabaseInfo { name: name.to_string(), ..Default::default() }
}
#[test]
+13 -1
View File
@@ -1,8 +1,20 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DatabaseInfo {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_charset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_collation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+1
View File
@@ -390,6 +390,7 @@ async fn main() {
.route("/agents/progress/{operationId}", get(routes::agents::agent_progress))
// Schema
.route("/schema/databases", get(routes::schema::list_databases))
.route("/schema/database-metadata", get(routes::schema::list_database_metadata))
.route("/schema/database-storage", post(routes::schema::list_database_storage))
.route("/schema/sqlserver/completion-context", get(routes::schema::get_sqlserver_completion_context))
.route("/schema/doris/catalogs", get(routes::schema::list_doris_catalogs))
+9
View File
@@ -42,6 +42,15 @@ pub async fn list_databases(
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
}
pub async fn list_database_metadata(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let result =
dbx_core::schema::list_database_metadata_core(&state.app, &q.connection_id).await.map_err(AppError::from)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
}
pub async fn list_database_storage(
State(state): State<Arc<WebState>>,
Json(request): Json<DatabaseStorageRequest>,