fix(sql): improve kingbase metadata loading and dangerous sql confirmation
This commit is contained in:
parent
47ddbd5ce7
commit
e780e66e41
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue