diff --git a/apps/desktop/src/lib/databaseTree.ts b/apps/desktop/src/lib/databaseTree.ts index d5eca7cc0..8c1b38e16 100644 --- a/apps/desktop/src/lib/databaseTree.ts +++ b/apps/desktop/src/lib/databaseTree.ts @@ -1,6 +1,13 @@ -import type { DatabaseInfo, TreeNode } from "@/types/database"; +import type { ConnectionConfig, DatabaseInfo, TreeNode } from "@/types/database"; import { DEFAULT_DATABASE_TREE_LABEL } from "./treeNodeContext"; +export function shouldIncludeDefaultDatabaseNode( + connection: Pick | undefined, + databases: DatabaseInfo[], +): boolean { + return connection?.db_type === "mysql" && databases.some((database) => !database.name.trim()); +} + export function buildDatabaseTreeNodes( connectionId: string, databases: DatabaseInfo[], diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 359bd3d0d..08e9e839d 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -21,7 +21,11 @@ import type { SqlCompletionColumn, SqlCompletionObject, SqlCompletionTable } fro import * as api from "@/lib/api"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { isSchemaAware, usesTreeSchemaMode } from "@/lib/databaseCapabilities"; -import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes } from "@/lib/databaseTree"; +import { + buildDatabaseTreeNodes, + buildDuckDbConnectionTreeNodes, + shouldIncludeDefaultDatabaseNode, +} from "@/lib/databaseTree"; import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree"; import { findDatabaseTreeNode } from "@/lib/treeRefreshTarget"; import { shouldMarkDisconnected } from "@/lib/connectionHealth"; @@ -852,7 +856,8 @@ export const useConnectionStore = defineStore("connection", () => { const children = withSavedSqlRoot( connectionId, buildDatabaseTreeNodes(connectionId, visibleDatabases, { - includeDefaultWhenEmpty: usesTreeSchemaMode(config?.db_type), + includeDefaultWhenEmpty: + usesTreeSchemaMode(config?.db_type) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), }), node, ); diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index f5faa2d2c..d19fbee12 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -718,22 +718,50 @@ pub async fn connect_bare(url: &str, fallback_timeout: Duration) -> Result Result, String> { let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; - let result = conn - .query_iter("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME") - .await - .map_err(|e| e.to_string())?; + let result = match conn.query_iter("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME").await + { + Ok(result) => result, + Err(err) => { + log::debug!("Falling back to SHOW DATABASES after information_schema.SCHEMATA failed: {err}"); + return list_databases_show(pool).await; + } + }; let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + let databases = database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), false); - Ok(rows.iter().map(|row| DatabaseInfo { name: get_str(row, 0) }).collect()) + if databases.is_empty() { + log::debug!("Falling back to SHOW DATABASES after information_schema.SCHEMATA returned no named databases"); + return list_databases_show(pool).await; + } + + Ok(databases) } pub async fn list_databases_show(pool: &MySqlPool) -> Result, String> { let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let result = conn.query_iter("SHOW DATABASES").await.map_err(|e| e.to_string())?; let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; - let mut databases: Vec = rows.iter().map(|row| DatabaseInfo { name: get_str(row, 0) }).collect(); + Ok(database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), true)) +} + +fn database_infos_from_names( + names: impl IntoIterator, + include_catalogless_when_blank: bool, +) -> Vec { + let mut saw_row = false; + let mut databases: Vec = names + .into_iter() + .filter_map(|name| { + saw_row = true; + let name = name.trim().to_string(); + (!name.is_empty()).then_some(DatabaseInfo { name }) + }) + .collect(); databases.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(databases) + if databases.is_empty() && saw_row && include_catalogless_when_blank { + return vec![DatabaseInfo { name: String::new() }]; + } + databases } pub async fn list_tables(pool: &MySqlPool, database: &str) -> Result, String> { @@ -742,19 +770,37 @@ pub async fn list_tables(pool: &MySqlPool, database: &str) -> Result result, + Err(err) => { + log::debug!( + "Falling back to SHOW TABLES for database `{database}` after information_schema.TABLES failed: {err}" + ); + return list_tables_show(pool, database).await; + } + }; let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; - Ok(rows + let tables: Vec = rows .iter() - .map(|row| TableInfo { - name: get_str_by_name(row, "TABLE_NAME"), - table_type: get_str_by_name(row, "TABLE_TYPE"), - comment: get_opt_str(row, "TABLE_COMMENT").filter(|s| !s.is_empty()), - parent_schema: None, - parent_name: None, + .filter_map(|row| { + let name = get_str_by_name(row, "TABLE_NAME").trim().to_string(); + (!name.is_empty()).then_some(TableInfo { + name, + table_type: get_str_by_name(row, "TABLE_TYPE"), + comment: get_opt_str(row, "TABLE_COMMENT").filter(|s| !s.is_empty()), + parent_schema: None, + parent_name: None, + }) }) - .collect()) + .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(pool, database).await; + } + + Ok(tables) } #[derive(Clone, Debug, Default)] @@ -765,7 +811,11 @@ struct TableStatusMeta { } async fn list_table_status_show(pool: &MySqlPool, database: &str) -> Result, String> { - let sql = format!("SHOW TABLE STATUS FROM {}", quote_identifier(database)); + let sql = if database.trim().is_empty() { + "SHOW TABLE STATUS".to_string() + } else { + format!("SHOW TABLE STATUS FROM {}", quote_identifier(database)) + }; let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?; let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; @@ -786,33 +836,46 @@ async fn list_table_status_show(pool: &MySqlPool, database: &str) -> Result Result, String> { - let sql = format!("SHOW FULL TABLES FROM {}", quote_identifier(database)); + let sql = show_tables_sql(database, true); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let rows: Vec = match conn.query_iter(&sql).await { Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?, Err(_) => { - let sql = format!("SHOW TABLES FROM {}", quote_identifier(database)); + let sql = show_tables_sql(database, 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 mut tables: Vec = rows .iter() - .map(|row| { + .filter_map(|row| { + let name = get_str(row, 0).trim().to_string(); + if name.is_empty() { + return None; + } let table_type = get_str(row, 1); - TableInfo { - name: get_str(row, 0), + Some(TableInfo { + name, table_type: if table_type.is_empty() { "TABLE".to_string() } else { table_type }, comment: None, parent_schema: None, parent_name: None, - } + }) }) .collect(); tables.sort_by(|a, b| a.name.cmp(&b.name)); Ok(tables) } +fn show_tables_sql(database: &str, full: bool) -> String { + let prefix = if full { "SHOW FULL TABLES" } else { "SHOW TABLES" }; + if database.trim().is_empty() { + prefix.to_string() + } else { + format!("{prefix} FROM {}", quote_identifier(database)) + } +} + async fn list_tables_show_with_status( pool: &MySqlPool, database: &str, @@ -1005,16 +1068,27 @@ fn columns_sql(database: &str, table: &str) -> String { pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Result, String> { let sql = columns_sql(database, table); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; - let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?; + 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 = result.collect_and_drop().await.map_err(|e| e.to_string())?; - Ok(rows + let columns: Vec = rows .iter() - .map(|row| { - let name = get_str_by_name(row, "COLUMN_NAME"); + .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 from_pk_join = row.get::("is_pk").unwrap_or(0) == 1; - ColumnInfo { + Some(ColumnInfo { is_primary_key: from_pk_join || column_key.eq_ignore_ascii_case("PRI"), name, data_type: get_str_by_name(row, "COLUMN_TYPE"), @@ -1025,28 +1099,41 @@ pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Resul 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"), - } + }) }) - .collect()) + .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; + } + + Ok(columns) } pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) -> Result, String> { - let sql = format!("SHOW FULL COLUMNS FROM {}.{}", quote_identifier(database), quote_identifier(table)); + let sql = show_columns_sql(database, table, true); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let rows: Vec = match conn.query_iter(&sql).await { Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?, Err(_) => { - let sql = format!("SHOW COLUMNS FROM {}.{}", quote_identifier(database), quote_identifier(table)); + 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())? } }; Ok(rows .iter() - .map(|row| { + .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"); - ColumnInfo { - name: get_str_by_name(row, "Field"), + Some(ColumnInfo { + name, data_type: get_str_by_name(row, "Type"), is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"), column_default: get_opt_str(row, "Default"), @@ -1056,11 +1143,20 @@ pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) -> numeric_precision: None, numeric_scale: None, character_maximum_length: None, - } + }) }) .collect()) } +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 { max_rows.unwrap_or(crate::query::MAX_ROWS).max(1) } @@ -1485,6 +1581,27 @@ mod tests { assert!(sql.contains("UPDATE_TIME")); } + #[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!["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![""]); + + let no_marker = database_infos_from_names(vec!["".to_string()], false); + assert!(no_marker.is_empty()); + } + + #[test] + fn mysql_show_metadata_sql_supports_catalogless_services() { + assert_eq!(show_tables_sql("", true), "SHOW FULL TABLES"); + assert_eq!(show_tables_sql("", false), "SHOW TABLES"); + assert_eq!(show_tables_sql("app", true), "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"); diff --git a/packages/app-tests/databaseTree.test.ts b/packages/app-tests/databaseTree.test.ts index ee63035c3..76e45ab29 100644 --- a/packages/app-tests/databaseTree.test.ts +++ b/packages/app-tests/databaseTree.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes } from "../../apps/desktop/src/lib/databaseTree.ts"; +import { + buildDatabaseTreeNodes, + buildDuckDbConnectionTreeNodes, + shouldIncludeDefaultDatabaseNode, +} from "../../apps/desktop/src/lib/databaseTree.ts"; test("设置默认库后侧边栏数据库树仍保留全部数据库", () => { const nodes = buildDatabaseTreeNodes("conn-1", [{ name: "campaign_data" }, { name: "cms" }, { name: "mk_campaign" }]); @@ -36,6 +40,12 @@ test("tree schema mode can show a default node when no catalog is returned", () assert.equal(nodes[0].label, "tree.defaultDatabase"); }); +test("MySQL-compatible catalogless services can opt into the default database node", () => { + assert.equal(shouldIncludeDefaultDatabaseNode({ db_type: "mysql" }, [{ name: "" }]), true); + assert.equal(shouldIncludeDefaultDatabaseNode({ db_type: "mysql" }, [{ name: "app" }]), false); + assert.equal(shouldIncludeDefaultDatabaseNode({ db_type: "postgres" }, [{ name: "" }]), false); +}); + test("DuckDB shows primary catalog schemas directly under the connection", () => { const nodes = buildDuckDbConnectionTreeNodes( "conn-1",