From e780e66e419608dea48dbb9b288751019e70994a Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 5 Jun 2026 12:48:05 +0800 Subject: [PATCH] fix(sql): improve kingbase metadata loading and dangerous sql confirmation --- apps/desktop/src/App.vue | 3 + .../components/editor/DangerConfirmDialog.vue | 16 + .../editor/EditorSettingsDialog.vue | 15 + .../src/components/layout/AppDialogs.vue | 5 + .../src/composables/useSqlExecution.ts | 11 +- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + apps/desktop/src/stores/settingsStore.ts | 6 + crates/dbx-core/src/schema.rs | 302 +++++++++++++++++- packages/app-tests/settingsStore.test.ts | 6 + .../main/java/app/dbx/jdbc/DbxJdbcPlugin.java | 37 ++- .../java/app/dbx/jdbc/DbxJdbcPluginTest.java | 6 + 14 files changed, 399 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 5e34306a8..97768bddd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -193,6 +193,7 @@ const { dangerSql, pendingDangerSql, showDangerDialog, + suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, @@ -1192,9 +1193,11 @@ onUnmounted(() => { :app-version="appVersion" :show-danger-dialog="showDangerDialog" :danger-sql="dangerSql" + :suppress-danger-confirm="suppressDangerConfirm" @update:show-connection-dialog="setConnectionDialogOpen" @update:show-settings-dialog="showSettingsDialog = $event" @update:show-danger-dialog="showDangerDialog = $event" + @update:suppress-danger-confirm="suppressDangerConfirm = $event" @danger-confirm="onDangerConfirm" @connect-started="(name: string) => toast(t('connection.connecting', { name }), 30000)" @connect-succeeded="(name: string) => toast(t('connection.connectSuccess', { name }), 2000)" diff --git a/apps/desktop/src/components/editor/DangerConfirmDialog.vue b/apps/desktop/src/components/editor/DangerConfirmDialog.vue index 11efc4718..7800a6dd8 100644 --- a/apps/desktop/src/components/editor/DangerConfirmDialog.vue +++ b/apps/desktop/src/components/editor/DangerConfirmDialog.vue @@ -4,12 +4,15 @@ import { useI18n } from "vue-i18n"; import { AlertTriangle } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { useSqlHighlighter } from "@/composables/useSqlHighlighter"; const { t } = useI18n(); const { highlight } = useSqlHighlighter(); const open = defineModel("open", { default: false }); +const suppressFuturePrompts = defineModel("suppressFuturePrompts", { default: false }); const props = withDefaults( defineProps<{ @@ -18,6 +21,8 @@ const props = withDefaults( message?: string; details?: string; confirmLabel?: string; + showSuppressToggle?: boolean; + suppressToggleLabel?: string; }>(), { sql: "", @@ -25,6 +30,8 @@ const props = withDefaults( message: "", details: "", confirmLabel: "", + showSuppressToggle: false, + suppressToggleLabel: "", }, ); @@ -58,6 +65,15 @@ function onConfirm() { class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 min-w-0 font-mono whitespace-pre" v-html="highlightedCode" /> +
+ + +
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index bbf10d98e..10772c1c7 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -105,6 +105,7 @@ const editUiScale = ref(settingsStore.editorSettings.uiScale); const editTheme = ref(settingsStore.editorSettings.theme); const editExecuteMode = ref(settingsStore.editorSettings.executeMode); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); +const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confirmDangerousSqlExecution); const editAppLayout = ref(settingsStore.editorSettings.appLayout); const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon); const editIconTheme = ref(settingsStore.desktopSettings.icon_theme); @@ -257,6 +258,7 @@ watch( editTheme.value = settingsStore.editorSettings.theme; editExecuteMode.value = settingsStore.editorSettings.executeMode; editWordWrap.value = settingsStore.editorSettings.wordWrap; + editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution; editAppLayout.value = settingsStore.editorSettings.appLayout; editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon; editIconTheme.value = settingsStore.desktopSettings.icon_theme; @@ -300,6 +302,7 @@ function hasChanges(): boolean { editTheme.value !== settingsStore.editorSettings.theme || editExecuteMode.value !== settingsStore.editorSettings.executeMode || editWordWrap.value !== settingsStore.editorSettings.wordWrap || + editConfirmDangerousSqlExecution.value !== settingsStore.editorSettings.confirmDangerousSqlExecution || editAppLayout.value !== settingsStore.editorSettings.appLayout || editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon || editIconTheme.value !== settingsStore.desktopSettings.icon_theme || @@ -332,6 +335,7 @@ async function persistSettings() { theme: editTheme.value, executeMode: editExecuteMode.value, wordWrap: editWordWrap.value, + confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value, appLayout: editAppLayout.value, showColumnCommentsInHeader: editShowColumnCommentsInHeader.value, compactColumnHeaderActions: editCompactColumnHeaderActions.value, @@ -373,6 +377,7 @@ function resetDefaults() { editTheme.value = DEFAULT_EDITOR_SETTINGS.theme; editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; + editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution; editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout; editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon; editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme; @@ -1264,6 +1269,16 @@ watch( +
+
+ +

+ {{ t("settings.confirmDangerousSqlExecutionDescription") }} +

+
+ +
+ diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 0831634f8..544d1ed5d 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -28,12 +28,14 @@ const props = defineProps<{ appVersion?: string; showDangerDialog: boolean; dangerSql: string; + suppressDangerConfirm: boolean; }>(); const emit = defineEmits<{ "update:showConnectionDialog": [value: boolean]; "update:showSettingsDialog": [value: boolean]; "update:showDangerDialog": [value: boolean]; + "update:suppressDangerConfirm": [value: boolean]; dangerConfirm: []; connectStarted: [name: string]; connectSucceeded: [name: string]; @@ -115,7 +117,10 @@ watch( v-if="showDangerDialog" :open="showDangerDialog" :sql="dangerSql" + :show-suppress-toggle="true" + :suppress-future-prompts="suppressDangerConfirm" @update:open="emit('update:showDangerDialog', $event)" + @update:suppress-future-prompts="emit('update:suppressDangerConfirm', $event)" @confirm="emit('dangerConfirm')" /> { return deps.resolveExecutableSql ? await deps.resolveExecutableSql() : deps.executableSql.value; @@ -56,9 +59,10 @@ export function useSqlExecution(deps: { const tab = deps.activeTab.value; const sql = sqlOverride ?? (await resolvedExecutableSql()); if (!tab || !sql.trim()) return; - if (isDangerousSql(sql)) { + if (isDangerousSql(sql) && settingsStore.editorSettings.confirmDangerousSqlExecution) { dangerSql.value = sql; pendingDangerSql.value = sql; + suppressDangerConfirm.value = false; showDangerDialog.value = true; } else { doExecute(sql); @@ -131,6 +135,10 @@ export function useSqlExecution(deps: { async function onDangerConfirm() { const sql = pendingDangerSql.value || (await resolvedExecutableSql()); + if (suppressDangerConfirm.value) { + settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false }); + } + suppressDangerConfirm.value = false; pendingDangerSql.value = ""; await doExecute(sql); } @@ -139,6 +147,7 @@ export function useSqlExecution(deps: { dangerSql, pendingDangerSql, showDangerDialog, + suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 56b49dcc6..51902fb56 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1382,6 +1382,7 @@ export default { dangerDialog: { title: "Dangerous Operation", message: "This SQL statement may modify or delete data irreversibly. Are you sure you want to execute it?", + suppressFuturePrompts: "Do not ask again for dangerous SQL", deleteMessage: "This delete operation may be irreversible. Continue?", deleteConfirm: "Confirm Delete", deleteRowMessage: "This row will be marked for deletion and removed from the database after saving. Continue?", @@ -1706,6 +1707,9 @@ export default { executeModeCurrent: "Execute statement at cursor", wordWrap: "Word wrap", wordWrapDescription: "Wrap long SQL lines within the editor width", + confirmDangerousSqlExecution: "Confirm before dangerous SQL", + confirmDangerousSqlExecutionDescription: + "When disabled, ALTER, DROP, DELETE, TRUNCATE, and other dangerous SQL run without the warning dialog.", redisScanPageSize: "Redis scan count", redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.", redisScanPageSizeOption: "{count} keys", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index cbf7f5d00..e0f7762d8 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1271,6 +1271,7 @@ export default { title: "Operación peligrosa", message: "Esta sentencia SQL puede modificar o eliminar datos de forma irreversible. ¿Estás seguro de que deseas ejecutarla?", + suppressFuturePrompts: "No volver a preguntar para SQL peligroso", deleteMessage: "Esta operación de eliminación puede ser irreversible. ¿Continuar?", deleteConfirm: "Confirmar eliminación", deleteRowMessage: @@ -1596,6 +1597,9 @@ export default { executeModeCurrent: "Ejecutar sentencia en el cursor", wordWrap: "Ajuste de línea", wordWrapDescription: "Ajustar las líneas largas de SQL al ancho del editor", + confirmDangerousSqlExecution: "Confirmar antes de SQL peligroso", + confirmDangerousSqlExecutionDescription: + "Cuando se desactiva, ALTER, DROP, DELETE, TRUNCATE y otras sentencias peligrosas se ejecutan sin el diálogo de advertencia.", redisScanPageSize: "Cantidad de escaneo Redis", redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.", redisScanPageSizeOption: "{count} claves", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e3fd47a61..1a7ee6060 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1357,6 +1357,7 @@ export default { dangerDialog: { title: "危险操作", message: "此 SQL 语句可能不可逆地修改或删除数据,确认要执行吗?", + suppressFuturePrompts: "以后执行危险 SQL 不再提示", deleteMessage: "此删除操作可能不可逆,确认要继续吗?", deleteConfirm: "确认删除", deleteRowMessage: "此行将被标记为删除,保存后会从数据库删除,确认要继续吗?", @@ -1664,6 +1665,8 @@ export default { executeModeCurrent: "执行光标所在语句", wordWrap: "自动换行", wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示", + confirmDangerousSqlExecution: "执行危险 SQL 前弹出确认", + confirmDangerousSqlExecutionDescription: "关闭后,ALTER、DROP、DELETE、TRUNCATE 等危险 SQL 将直接执行。", redisScanPageSize: "Redis 扫描数量", redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。", redisScanPageSizeOption: "{count} 个 Key", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 379e88c65..f625e597b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1335,6 +1335,7 @@ export default { dangerDialog: { title: "危險操作", message: "此 SQL 語句可能不可逆地修改或刪除資料,確認要執行嗎?", + suppressFuturePrompts: "之後執行危險 SQL 不再提示", deleteMessage: "此刪除操作可能不可逆,確認要繼續嗎?", deleteConfirm: "確認刪除", deleteRowMessage: "此列將被標記為刪除,儲存後會從資料庫刪除,確認要繼續嗎?", @@ -1641,6 +1642,8 @@ export default { executeModeCurrent: "執行指標所在語句", wordWrap: "自動換行", wordWrapDescription: "長 SQL 在編輯器寬度內自動折行顯示", + confirmDangerousSqlExecution: "執行危險 SQL 前彈出確認", + confirmDangerousSqlExecutionDescription: "關閉後,ALTER、DROP、DELETE、TRUNCATE 等危險 SQL 會直接執行。", redisScanPageSize: "Redis 掃描數量", redisScanPageSizeDescription: "瀏覽 Redis Key 時每次 SCAN 請求的 Key 數量。", redisScanPageSizeOption: "{count} 個 Key", diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 614a97a00..4e5af7377 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -197,6 +197,7 @@ export interface EditorSettings { theme: EditorTheme; executeMode: "all" | "current"; wordWrap: boolean; + confirmDangerousSqlExecution: boolean; compactTabTitle: boolean; appLayout: "separated" | "classic"; pageSize: number; @@ -256,6 +257,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { theme: "app", executeMode: "all", wordWrap: false, + confirmDangerousSqlExecution: true, compactTabTitle: false, appLayout: "classic", pageSize: 100, @@ -385,6 +387,8 @@ export function normalizeEditorSettings(settings: Partial, exist theme: settings.theme && EDITOR_THEME_VALUES.has(settings.theme) ? settings.theme : DEFAULT_EDITOR_SETTINGS.theme, executeMode: settings.executeMode ?? DEFAULT_EDITOR_SETTINGS.executeMode, wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap, + confirmDangerousSqlExecution: + settings.confirmDangerousSqlExecution ?? DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution, compactTabTitle: settings.compactTabTitle ?? DEFAULT_EDITOR_SETTINGS.compactTabTitle, appLayout: settings.appLayout ?? DEFAULT_EDITOR_SETTINGS.appLayout, pageSize: normalizeResultPageSize(settings.pageSize), @@ -541,6 +545,8 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.theme !== undefined) editorSettings.value.theme = partial.theme; if (partial.executeMode !== undefined) editorSettings.value.executeMode = partial.executeMode; if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap; + if (partial.confirmDangerousSqlExecution !== undefined) + editorSettings.value.confirmDangerousSqlExecution = partial.confirmDangerousSqlExecution; if (partial.compactTabTitle !== undefined) editorSettings.value.compactTabTitle = partial.compactTabTitle; if (partial.appLayout !== undefined) editorSettings.value.appLayout = partial.appLayout; if (partial.pageSize !== undefined) editorSettings.value.pageSize = normalizeResultPageSize(partial.pageSize); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index bd85b02b1..d432cfea1 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1,9 +1,10 @@ -use crate::connection::{AppState, MysqlMode, PoolKind}; +use crate::connection::{connection_url_for_endpoint, database_connection_config, AppState, MysqlMode, PoolKind}; use crate::db; use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::query::{agent_execute_query_params, QueryExecutionOptions}; use std::future::Future; use std::sync::Arc; +use std::time::Duration; macro_rules! extract_pool { ($connections:expr, $key:expr, $variant:ident) => { @@ -318,6 +319,7 @@ pub async fn list_schemas_core(state: &AppState, connection_id: &str, database: async fn list_schemas_once(state: &AppState, connection_id: &str, database: &str) -> Result, String> { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + let db_config = connection_config(state, connection_id).await; { let connections = state.connections.read().await; @@ -333,7 +335,43 @@ async fn list_schemas_once(state: &AppState, connection_id: &str, database: &str .await; } try_sqlserver!(connections, &pool_key, list_schemas); - try_agent!(connections, &pool_key, list_schemas, database); + if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { + let fallback_config = db_config.clone(); + drop(connections); + let mut client = client.lock().await; + match client.list_schemas::>(database).await { + Ok(schemas) if !schemas.is_empty() => return Ok(schemas), + Ok(schemas) => { + if let Some(config) = fallback_config.as_ref() { + match native_postgres_metadata_pool(state, connection_id, database, config).await { + Ok(Some(pool)) => return db::postgres::list_schemas(&pool).await, + Ok(None) => return Ok(schemas), + Err(error) => { + log::warn!( + "[schema][agent:list_schemas:fallback-failed] connection_id={} database={} error={}", + connection_id, + database, + error + ); + } + } + } + return Ok(schemas); + } + Err(agent_error) => { + if let Some(config) = fallback_config.as_ref() { + if let Some(pool) = + native_postgres_metadata_pool(state, connection_id, database, config).await? + { + return db::postgres::list_schemas(&pool).await.map_err(|fallback_error| { + format!("{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}") + }); + } + } + return Err(agent_error); + } + } + } } let connections = state.connections.read().await; @@ -409,7 +447,53 @@ async fn list_tables_once( return db::clickhouse_driver::list_tables(&client, clickhouse_metadata_database(database, schema)).await; } try_sqlserver!(connections, &pool_key, list_tables, schema, filter, limit); - try_agent!(connections, &pool_key, list_tables, database, schema); + if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { + let fallback_config = db_config.clone(); + drop(connections); + let mut client = client.lock().await; + match client.list_tables::>(database, schema).await { + Ok(tables) if !tables.is_empty() => return Ok(filter_table_infos(tables, filter, limit)), + Ok(tables) => { + if let Some(config) = fallback_config.as_ref() { + match native_postgres_metadata_pool(state, connection_id, database, config).await { + Ok(Some(pool)) => { + return db::postgres::list_tables(&pool, schema) + .await + .map(|tables| filter_table_infos(tables, filter, limit)); + } + Ok(None) => return Ok(filter_table_infos(tables, filter, limit)), + Err(error) => { + log::warn!( + "[schema][agent:list_tables:fallback-failed] connection_id={} database={} schema={} error={}", + connection_id, + database, + schema, + error + ); + } + } + } + return Ok(filter_table_infos(tables, filter, limit)); + } + Err(agent_error) => { + if let Some(config) = fallback_config.as_ref() { + if let Some(pool) = + native_postgres_metadata_pool(state, connection_id, database, config).await? + { + return db::postgres::list_tables(&pool, schema) + .await + .map(|tables| filter_table_infos(tables, filter, limit)) + .map_err(|fallback_error| { + format!( + "{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}" + ) + }); + } + } + return Err(agent_error); + } + } + } } let connections = state.connections.read().await; @@ -471,8 +555,9 @@ fn filter_table_infos(tables: Vec, filter: Option<&str>, limit: O mod tests { use super::{ clickhouse_metadata_database, deduplicate_column_infos, duckdb_attach_database, duckdb_list_databases, - duckdb_query_tables_in_database, + duckdb_query_tables_in_database, is_agent_postgres_metadata_fallback_config, }; + use crate::models::connection::{ConnectionConfig, DatabaseType, ProxyType}; fn test_column(name: &str, comment: Option<&str>, is_primary_key: bool) -> super::db::ColumnInfo { super::db::ColumnInfo { @@ -489,6 +574,59 @@ mod tests { } } + fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig { + ConnectionConfig { + id: "test".to_string(), + name: "test".to_string(), + db_type, + driver_profile: None, + driver_label: None, + url_params: None, + host: "127.0.0.1".to_string(), + port: 5432, + username: "user".to_string(), + password: "secret".to_string(), + database: Some("demo".to_string()), + visible_databases: None, + attached_databases: Vec::new(), + color: None, + ssh_enabled: false, + ssh_host: String::new(), + ssh_port: 22, + ssh_user: String::new(), + ssh_password: String::new(), + ssh_key_path: String::new(), + ssh_key_passphrase: String::new(), + ssh_expose_lan: false, + ssh_connect_timeout_secs: 5, + ssh_tunnels: Vec::new(), + connect_timeout_secs: 5, + query_timeout_secs: 30, + proxy_enabled: false, + proxy_type: ProxyType::Socks5, + proxy_host: String::new(), + proxy_port: 1080, + proxy_username: String::new(), + proxy_password: String::new(), + ssl: false, + ca_cert_path: String::new(), + sysdba: false, + oracle_connection_type: None, + connection_string: None, + redis_connection_mode: None, + redis_sentinel_master: String::new(), + redis_sentinel_nodes: String::new(), + redis_sentinel_username: String::new(), + redis_sentinel_password: String::new(), + redis_sentinel_tls: false, + redis_cluster_nodes: String::new(), + external_config: None, + jdbc_driver_class: None, + jdbc_driver_paths: Vec::new(), + one_time: false, + } + } + #[test] fn duckdb_list_databases_includes_attached_database() { let unique = uuid::Uuid::new_v4(); @@ -550,6 +688,15 @@ mod tests { assert_eq!(columns[1].name, "TFBH"); assert_eq!(columns[1].comment.as_deref(), Some("台账编号")); } + + #[test] + fn postgres_like_agent_metadata_fallback_targets_pg_compatible_agents() { + assert!(is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Kingbase))); + assert!(is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Highgo))); + assert!(is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Vastbase))); + assert!(!is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Postgres))); + assert!(!is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Mysql))); + } } pub async fn list_objects_core( @@ -623,12 +770,45 @@ async fn list_objects_once( try_sqlserver!(connections, &pool_key, list_objects, schema); if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle); + let fallback_config = db_config.clone(); drop(connections); if is_oracle { return oracle_agent_list_objects(client, database, schema).await; } let mut client = client.lock().await; - return client.list_objects(database, schema).await; + match client.list_objects::>(database, schema).await { + Ok(objects) if !objects.is_empty() => return Ok(objects), + Ok(objects) => { + if let Some(config) = fallback_config.as_ref() { + match native_postgres_metadata_pool(state, connection_id, database, config).await { + Ok(Some(pool)) => return db::postgres::list_objects(&pool, schema).await, + Ok(None) => return Ok(objects), + Err(error) => { + log::warn!( + "[schema][agent:list_objects:fallback-failed] connection_id={} database={} schema={} error={}", + connection_id, + database, + schema, + error + ); + } + } + } + return Ok(objects); + } + Err(agent_error) => { + if let Some(config) = fallback_config.as_ref() { + if let Some(pool) = + native_postgres_metadata_pool(state, connection_id, database, config).await? + { + return db::postgres::list_objects(&pool, schema).await.map_err(|fallback_error| { + format!("{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}") + }); + } + } + return Err(agent_error); + } + } } } @@ -691,12 +871,54 @@ async fn list_completion_objects_once( } if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle); + let fallback_config = db_config.clone(); drop(connections); let objects = if is_oracle { oracle_agent_list_objects(client, database, schema).await? } else { let mut client = client.lock().await; - client.list_objects(database, schema).await? + match client.list_objects::>(database, schema).await { + Ok(objects) if !objects.is_empty() => objects, + Ok(objects) => { + if let Some(config) = fallback_config.as_ref() { + match native_postgres_metadata_pool(state, connection_id, database, config).await { + Ok(Some(pool)) => { + return db::postgres::list_objects(&pool, schema).await.map(filter_completion_objects) + } + Ok(None) => objects, + Err(error) => { + log::warn!( + "[schema][agent:list_completion_objects:fallback-failed] connection_id={} database={} schema={} error={}", + connection_id, + database, + schema, + error + ); + objects + } + } + } else { + objects + } + } + Err(agent_error) => { + if let Some(config) = fallback_config.as_ref() { + if let Some(pool) = + native_postgres_metadata_pool(state, connection_id, database, config).await? + { + return db::postgres::list_objects(&pool, schema) + .await + .map(filter_completion_objects) + .map_err(|fallback_error| { + format!( + "{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}" + ) + }); + } + } + return Err(agent_error); + } + } }; return Ok(filter_completion_objects(objects)); } @@ -729,6 +951,28 @@ fn filter_completion_objects(objects: Vec) -> Vec bool { + matches!(config.db_type, DatabaseType::Kingbase | DatabaseType::Highgo | DatabaseType::Vastbase) +} + +async fn native_postgres_metadata_pool( + state: &AppState, + connection_id: &str, + database: &str, + config: &ConnectionConfig, +) -> Result, String> { + if !is_agent_postgres_metadata_fallback_config(config) { + return Ok(None); + } + + let mut postgres_config = database_connection_config(config, Some(database)); + postgres_config.db_type = DatabaseType::Postgres; + let (host, port) = state.connection_host_port(connection_id, &postgres_config).await?; + let url = connection_url_for_endpoint(&postgres_config, &host, port); + let connect_timeout = Duration::from_secs(postgres_config.effective_connect_timeout_secs()); + db::postgres::connect(&url, connect_timeout).await.map(Some) +} + async fn retry_metadata_connection( state: &AppState, connection_id: &str, @@ -809,10 +1053,52 @@ pub async fn get_columns_core( } try_sqlserver!(connections, &pool_key, get_columns, schema, table); if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { + let fallback_config = db_config.clone(); drop(connections); let mut client = client.lock().await; - let columns = client.get_columns::>(database, schema, table).await?; - return Ok(deduplicate_column_infos(columns)); + match client.get_columns::>(database, schema, table).await { + Ok(columns) if !columns.is_empty() => return Ok(deduplicate_column_infos(columns)), + Ok(columns) => { + if let Some(config) = fallback_config.as_ref() { + match native_postgres_metadata_pool(state, connection_id, database, config).await { + Ok(Some(pool)) => { + return db::postgres::get_columns(&pool, schema, table) + .await + .map(deduplicate_column_infos); + } + Ok(None) => return Ok(deduplicate_column_infos(columns)), + Err(error) => { + log::warn!( + "[schema][agent:get_columns:fallback-failed] connection_id={} database={} schema={} table={} error={}", + connection_id, + database, + schema, + table, + error + ); + } + } + } + return Ok(deduplicate_column_infos(columns)); + } + Err(agent_error) => { + if let Some(config) = fallback_config.as_ref() { + if let Some(pool) = + native_postgres_metadata_pool(state, connection_id, database, config).await? + { + return db::postgres::get_columns(&pool, schema, table) + .await + .map(deduplicate_column_infos) + .map_err(|fallback_error| { + format!( + "{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}" + ) + }); + } + } + return Err(agent_error); + } + } } } diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 8a1ee2463..bf0f53754 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -37,6 +37,12 @@ test("normalizes editor theme settings", () => { assert.equal(normalizeEditorSettings({ theme: "invalid" as any }).theme, DEFAULT_EDITOR_SETTINGS.theme); }); +test("defaults dangerous SQL confirmation to enabled", () => { + assert.equal(DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution, true); + assert.equal(normalizeEditorSettings({}).confirmDangerousSqlExecution, true); + assert.equal(normalizeEditorSettings({ confirmDangerousSqlExecution: false }).confirmDangerousSqlExecution, false); +}); + test("defaults shortcut settings", () => { const settings = normalizeEditorSettings({}); diff --git a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java index 22b02dc5c..0a0c12e6f 100644 --- a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java +++ b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java @@ -44,17 +44,19 @@ import java.util.logging.Logger; public final class DbxJdbcPlugin { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final int MAX_ROWS = 10_000; - private static final JdbcDriverQuirks DEFAULT_QUIRKS = new JdbcDriverQuirks(false, false, false, false); - private static final JdbcDriverQuirks USE_CATALOG_QUIRKS = new JdbcDriverQuirks(false, false, false, true); - private static final JdbcDriverQuirks YASHAN_QUIRKS = new JdbcDriverQuirks(true, true, false, false); - private static final JdbcDriverQuirks IRIS_QUIRKS = new JdbcDriverQuirks(true, false, true, false); - private static final JdbcDriverQuirks ORACLE_QUIRKS = new JdbcDriverQuirks(false, true, false, false); + private static final JdbcDriverQuirks DEFAULT_QUIRKS = new JdbcDriverQuirks(false, false, false, false, false); + private static final JdbcDriverQuirks USE_CATALOG_QUIRKS = new JdbcDriverQuirks(false, false, false, true, false); + private static final JdbcDriverQuirks KINGBASE_QUIRKS = new JdbcDriverQuirks(false, false, false, false, true); + private static final JdbcDriverQuirks YASHAN_QUIRKS = new JdbcDriverQuirks(true, true, false, false, false); + private static final JdbcDriverQuirks IRIS_QUIRKS = new JdbcDriverQuirks(true, false, true, false, false); + private static final JdbcDriverQuirks ORACLE_QUIRKS = new JdbcDriverQuirks(false, true, false, false, false); private static final List DRIVER_QUIRK_RULES = List.of( new JdbcDriverQuirkRule("jdbc:mysql:", USE_CATALOG_QUIRKS), new JdbcDriverQuirkRule("jdbc:mariadb:", USE_CATALOG_QUIRKS), new JdbcDriverQuirkRule("jdbc:starrocks:", USE_CATALOG_QUIRKS), new JdbcDriverQuirkRule("jdbc:doris:", USE_CATALOG_QUIRKS), new JdbcDriverQuirkRule("jdbc:hive2:", USE_CATALOG_QUIRKS), + new JdbcDriverQuirkRule("jdbc:kingbase", KINGBASE_QUIRKS), new JdbcDriverQuirkRule("jdbc:yasdb:", YASHAN_QUIRKS), new JdbcDriverQuirkRule("jdbc:iris:", IRIS_QUIRKS), new JdbcDriverQuirkRule("jdbc:oracle:", ORACLE_QUIRKS), @@ -68,7 +70,8 @@ public final class DbxJdbcPlugin { boolean skipExecutionContext, boolean useOracleMetadata, boolean caseInsensitiveSchemaMetadata, - boolean useCatalogFallbackSql + boolean useCatalogFallbackSql, + boolean ignoreCatalogForSchemaMetadata ) { } @@ -438,12 +441,13 @@ public final class DbxJdbcPlugin { ArrayNode result = MAPPER.createArrayNode(); Connection conn = openConnection(connection); JdbcDriverQuirks quirks = driverQuirks(connection); + String catalog = metadataCatalog(database, quirks); if (quirks.useOracleMetadata()) { return oracleListSchemas(conn); } DatabaseMetaData meta = conn.getMetaData(); if (quirks.caseInsensitiveSchemaMetadata()) { - try (ResultSet rs = meta.getSchemas(emptyToNull(database), null)) { + try (ResultSet rs = meta.getSchemas(catalog, null)) { appendSchemas(result, rs, true); } catch (SQLException ignored) { try (ResultSet rs = meta.getSchemas()) { @@ -455,14 +459,14 @@ public final class DbxJdbcPlugin { } catch (SQLException ignored) { } } else { - try (ResultSet rs = meta.getSchemas(emptyToNull(database), null)) { + try (ResultSet rs = meta.getSchemas(catalog, null)) { appendSchemas(result, rs, false); } catch (SQLFeatureNotSupportedException ignored) { try (ResultSet rs = meta.getSchemas()) { appendSchemas(result, rs, false); } } - if (result.isEmpty() && database != null) { + if (result.isEmpty() && catalog != null) { try (ResultSet rs = meta.getSchemas(null, null)) { appendSchemas(result, rs, false); } catch (SQLFeatureNotSupportedException ignored) { @@ -490,7 +494,7 @@ public final class DbxJdbcPlugin { } String[] types = new String[] {"TABLE", "VIEW", "MATERIALIZED VIEW", "SYSTEM TABLE", "SYSTEM VIEW"}; DatabaseMetaData meta = conn.getMetaData(); - String catalog = quirks.caseInsensitiveSchemaMetadata() ? null : emptyToNull(database); + String catalog = metadataCatalog(database, quirks); String schemaPattern = resolveSchemaPattern(meta, database, schema, quirks); appendTables(result, meta, catalog, schemaPattern, types); if (result.isEmpty() && catalog != null) { @@ -507,7 +511,7 @@ public final class DbxJdbcPlugin { } DatabaseMetaData meta = conn.getMetaData(); JdbcDriverQuirks quirks = driverQuirks(connection); - String catalog = quirks.caseInsensitiveSchemaMetadata() ? null : emptyToNull(database); + String catalog = metadataCatalog(database, quirks); String schemaPattern = resolveSchemaPattern(meta, database, schema, quirks); String[] tableTypes = new String[] {"TABLE", "VIEW", "MATERIALIZED VIEW", "SYSTEM TABLE", "SYSTEM VIEW"}; @@ -560,7 +564,7 @@ public final class DbxJdbcPlugin { } DatabaseMetaData meta = conn.getMetaData(); JdbcDriverQuirks quirks = driverQuirks(connection); - String catalog = quirks.caseInsensitiveSchemaMetadata() ? null : emptyToNull(database); + String catalog = metadataCatalog(database, quirks); String schemaPattern = resolveSchemaPattern(meta, database, schema, quirks); Set primaryKeys = safePrimaryKeys(meta, catalog, schemaPattern, table); appendColumns(result, meta, catalog, schemaPattern, table, primaryKeys); @@ -610,6 +614,13 @@ public final class DbxJdbcPlugin { return caseInsensitive ? schema.toLowerCase(Locale.ROOT) : schema; } + private static String metadataCatalog(String database, JdbcDriverQuirks quirks) { + if (quirks.caseInsensitiveSchemaMetadata() || quirks.ignoreCatalogForSchemaMetadata()) { + return null; + } + return emptyToNull(database); + } + private static String resolveSchemaPattern( DatabaseMetaData meta, String database, @@ -622,7 +633,7 @@ public final class DbxJdbcPlugin { } String resolved = null; try { - resolved = findSchemaPattern(meta, emptyToNull(database), schemaPattern); + resolved = findSchemaPattern(meta, metadataCatalog(database, quirks), schemaPattern); } catch (SQLException ignored) { } if (resolved != null) { diff --git a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java index 3865042ae..64a24f495 100644 --- a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java +++ b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java @@ -230,6 +230,11 @@ final class DbxJdbcPluginTest { "connection_string": "jdbc:mysql://127.0.0.1:9030/demo" } """); + JsonNode kingbase = MAPPER.readTree(""" + { + "connection_string": "jdbc:kingbase8://127.0.0.1:54321/demo" + } + """); JsonNode kyuubi = MAPPER.readTree(""" { "jdbc_driver_class": "org.apache.kyuubi.jdbc.KyuubiHiveDriver" @@ -246,6 +251,7 @@ final class DbxJdbcPluginTest { assertEquals(false, DbxJdbcPlugin.driverQuirks(h2).caseInsensitiveSchemaMetadata()); assertEquals(false, DbxJdbcPlugin.driverQuirks(h2).useCatalogFallbackSql()); assertEquals(true, DbxJdbcPlugin.driverQuirks(mysql).useCatalogFallbackSql()); + assertEquals(true, DbxJdbcPlugin.driverQuirks(kingbase).ignoreCatalogForSchemaMetadata()); assertEquals(true, DbxJdbcPlugin.driverQuirks(kyuubi).useCatalogFallbackSql()); }