fix(sql): improve kingbase metadata loading and dangerous sql confirmation

This commit is contained in:
t8y2 2026-06-05 12:48:05 +08:00
parent 47ddbd5ce7
commit e780e66e41
14 changed files with 399 additions and 22 deletions

View File

@ -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)"

View File

@ -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<boolean>("open", { default: false });
const suppressFuturePrompts = defineModel<boolean>("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"
/>
<div
v-if="showSuppressToggle"
class="mt-3 flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2"
>
<Label for="danger-confirm-suppress" class="text-sm leading-5">{{
suppressToggleLabel || t("dangerDialog.suppressFuturePrompts")
}}</Label>
<Switch id="danger-confirm-suppress" v-model="suppressFuturePrompts" />
</div>
</div>
<DialogFooter>

View File

@ -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<DesktopIconTheme>(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(
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-confirm-dangerous-sql">{{ t("settings.confirmDangerousSqlExecution") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.confirmDangerousSqlExecutionDescription") }}
</p>
</div>
<Switch id="editor-confirm-dangerous-sql" v-model="editConfirmDangerousSqlExecution" class="mt-0.5" />
</div>
<Separator />
<!-- Live Preview -->

View File

@ -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')"
/>
<DataTransferDialog

View File

@ -3,6 +3,7 @@ import { useI18n } from "vue-i18n";
import { useQueryStore } from "@/stores/queryStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { classifySqlActivityKind } from "@/lib/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sqlMetadataRefresh";
@ -42,11 +43,13 @@ export function useSqlExecution(deps: {
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const dangerSql = ref("");
const pendingDangerSql = ref("");
const showDangerDialog = ref(false);
const suppressDangerConfirm = ref(false);
async function resolvedExecutableSql(): Promise<string> {
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,

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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<EditorSettings>, 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);

View File

@ -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<Vec<String>, 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::<Vec<String>>(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::<Vec<db::TableInfo>>(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<db::TableInfo>, 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::<Vec<db::ObjectInfo>>(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::<Vec<db::ObjectInfo>>(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<db::ObjectInfo>) -> Vec<db::ObjectInfo
.collect()
}
fn is_agent_postgres_metadata_fallback_config(config: &ConnectionConfig) -> 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<Option<deadpool_postgres::Pool>, 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<T, F, Fut>(
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::<Vec<db::ColumnInfo>>(database, schema, table).await?;
return Ok(deduplicate_column_infos(columns));
match client.get_columns::<Vec<db::ColumnInfo>>(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);
}
}
}
}

View File

@ -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({});

View File

@ -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<JdbcDriverQuirkRule> 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<String> 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) {

View File

@ -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());
}