feat(oracle): add dblink completion, tree view and management

This commit is contained in:
zipg authored and GitHub committed 2026-09-13 02:00:09 +08:00
1 parent 6694e4382e
commit b8f39f9c24
25 files changed
+645 -13

No files matched your search

@@ -177,6 +177,7 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, queryTableNavigationTargetAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
import { oracleDatabaseLinkCompletionContext, oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isMacOS } from "@/lib/backend/platform";
import {
@@ -4619,6 +4620,7 @@ async function provideSqlCompletions(context: CompletionContext) {
if (props.databaseType === "victoriametrics") return null;
const hasDatabase = props.database != null;
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
const epoch = ++completionEpoch;
@@ -4647,12 +4649,12 @@ async function provideSqlCompletions(context: CompletionContext) {
// require-prefix: only compute local facts (no positionalEligible).
if (mode === "require-prefix") {
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const facts: SqlCompletionTriggerFacts = {
origin,
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: prevChar === "." && ("from" in ctx ? ctx.schema != null : ctx.qualifier != null),
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
};
if (!shouldAllowSqlCompletionTrigger(mode, facts)) return null;
@@ -4660,13 +4662,13 @@ async function provideSqlCompletions(context: CompletionContext) {
// positional: compute positionalEligible (lazy).
if (mode === "positional") {
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
const facts: SqlCompletionTriggerFacts = {
origin,
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: prevChar === "." && ("from" in ctx ? ctx.schema != null : ctx.qualifier != null),
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
positionalEligible,
};
@@ -4702,6 +4704,27 @@ async function provideSqlCompletions(context: CompletionContext) {
return buildCompletionResult(items, useDatabaseCompletion.from, undefined, useDatabaseCompletion.prefix);
}
if (databaseLinkContext) {
if (!hasDatabase) return null;
const links = await connectionStore.listOracleDatabaseLinks(props.connectionId, props.database!);
if (epoch !== completionEpoch) return null;
return {
from: databaseLinkContext.from,
to: databaseLinkContext.to,
options: oracleDatabaseLinkCompletionItems(links, databaseLinkContext.prefix).map((item) => ({
...item,
apply(editor: EditorViewType, _completion: unknown, from: number, to: number) {
markCompletionAccepted({ label: item.label, type: "text", boost: 0 });
// Keep the entire link suffix intact and use the normal completion
// transaction so accepting a link does not immediately reopen the menu.
if (codeMirrorInsertCompletionText) editor.dispatch(codeMirrorInsertCompletionText(editor.state, item.apply, from, to));
else editor.dispatch({ changes: { from, to, insert: item.apply }, selection: { anchor: from + item.apply.length } });
},
})),
validFor: /^[A-Za-z0-9_$#.]*$/,
};
}
if (sequenceLiteralContext) {
if (!hasDatabase) return null;
const sequences = await connectionStore.listCompletionObjects(props.connectionId, props.database!, sequenceLiteralContext.prefix, MAX_COMPLETION_TABLES, sequenceLiteralContext.schema, undefined, false, undefined, ["sequence"], sequenceLiteralContext.nameQuoted);
@@ -4912,6 +4935,7 @@ function flushImeComposition() {
*/
function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): boolean {
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: view.value?.state }) && !sequenceLiteralContext) return false;
const mode = settingsStore.editorSettings.completionTriggerMode;
if (mode === "manual") return false;
@@ -4924,25 +4948,25 @@ function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
if (mode === "require-prefix") {
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const facts: SqlCompletionTriggerFacts = {
origin: "typing",
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: prevChar === "." && ("from" in ctx ? ctx.schema != null : ctx.qualifier != null),
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
};
return shouldAllowSqlCompletionTrigger(mode, facts);
}
// positional
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
const facts: SqlCompletionTriggerFacts = {
origin: "typing",
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: prevChar === "." && ("from" in ctx ? ctx.schema != null : ctx.qualifier != null),
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
positionalEligible,
};
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { alterOracleDatabaseLinkSql, createOracleDatabaseLinkSql, dropOracleDatabaseLinkSql, testOracleDatabaseLinkSql, type OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
const props = defineProps<{ open: boolean; connectionId: string; database: string; name?: string; owner?: string }>();
const emit = defineEmits<{ "update:open": [boolean]; changed: [] }>();
const { t } = useI18n();
const store = useConnectionStore();
const links = ref<OracleDatabaseLink[]>([]);
const sessionUser = ref("");
const selected = ref<OracleDatabaseLink>();
const search = ref("");
const busy = ref(false);
const message = ref("");
const error = ref("");
const mode = ref<"create" | "alter" | "drop" | "">("");
const name = ref("");
const username = ref("");
const password = ref("");
const host = ref("");
const publicLink = ref(false);
const filtered = computed(() => links.value.filter((link) => `${link.name} ${link.owner} ${link.username} ${link.host}`.toLowerCase().includes(search.value.toLowerCase())));
const selectedIsShadowed = computed(() => selected.value?.owner === "PUBLIC" && links.value.some((link) => link.owner !== "PUBLIC" && link.name.toUpperCase() === selected.value?.name.toUpperCase()));
let revision = 0;
watch(
() => [props.open, props.connectionId, props.database],
() => {
++revision;
mode.value = "";
password.value = "";
error.value = "";
message.value = "";
if (props.open) void reload();
},
{ immediate: true },
);
async function reload() {
const requested = ++revision;
busy.value = true;
error.value = "";
try {
const result = await store.listOracleDatabaseLinks(props.connectionId, props.database);
const identity = await api.executeQuery(props.connectionId, props.database, "SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL", undefined, undefined, { maxRows: 1, timeoutSecs: 15 });
if (requested !== revision) return;
sessionUser.value = String(identity.rows[0]?.[0] || "");
links.value = result;
selected.value = result.find((link) => link.name === (selected.value?.name || props.name) && link.owner === (selected.value?.owner || props.owner));
} catch (e) {
if (requested === revision) error.value = String(e);
} finally {
if (requested === revision) busy.value = false;
}
}
function begin(next: "create" | "alter" | "drop") {
mode.value = next;
password.value = "";
error.value = "";
message.value = "";
if (next === "create") {
name.value = "";
username.value = "";
host.value = "";
publicLink.value = false;
}
}
async function execute(sql: string) {
if (!sessionUser.value) throw new Error("Unable to determine the Oracle session user");
return executeWithProductionSqlGuard({ connection: store.getConfig(props.connectionId), database: props.database, sql, source: t("tree.databaseLinks"), execute: () => api.executeQuery(props.connectionId, props.database, sql, sessionUser.value, undefined, { maxRows: 1, timeoutSecs: 15 }) });
}
async function save() {
if (busy.value) return;
busy.value = true;
error.value = "";
try {
const sql =
mode.value === "create"
? createOracleDatabaseLinkSql({ name: name.value.trim(), username: username.value.trim(), password: password.value, host: host.value, public: publicLink.value })
: mode.value === "alter" && selected.value
? alterOracleDatabaseLinkSql(selected.value, password.value)
: mode.value === "drop" && selected.value
? dropOracleDatabaseLinkSql(selected.value)
: "";
if (!sql) return;
const result = await execute(sql);
if (result === undefined) return;
mode.value = "";
password.value = "";
message.value = t("databaseLinks.saved");
emit("changed");
await reload();
} catch (e) {
error.value = String(e);
} finally {
busy.value = false;
}
}
async function test() {
if (!selected.value || busy.value || selectedIsShadowed.value) return;
busy.value = true;
error.value = "";
message.value = "";
try {
const result = await execute(testOracleDatabaseLinkSql(selected.value));
if (result === undefined) return;
message.value = t("databaseLinks.testSuccess");
} catch (e) {
error.value = String(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader
><DialogTitle>{{ t("tree.databaseLinks") }}</DialogTitle></DialogHeader
>
<div class="flex gap-2">
<Input v-model="search" :placeholder="t('databaseLinks.search')" :aria-label="t('databaseLinks.search')" />
<Button variant="outline" :disabled="busy" @click="reload">{{ t("common.refresh") }}</Button>
<Button :disabled="busy" @click="begin('create')">{{ t("databaseLinks.create") }}</Button>
</div>
<div class="max-h-64 overflow-auto border rounded-md">
<table class="w-full text-sm text-left">
<thead class="sticky top-0 bg-muted">
<tr>
<th class="p-2">{{ t("databaseLinks.name") }}</th>
<th class="p-2">{{ t("databaseLinks.owner") }}</th>
<th class="p-2">{{ t("databaseLinks.username") }}</th>
<th class="p-2">{{ t("databaseLinks.host") }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="link in filtered"
:key="`${link.owner}:${link.name}`"
class="cursor-pointer hover:bg-muted/60"
:class="selected === link ? 'bg-muted' : ''"
@click="
selected = link;
mode = '';
password = '';
"
>
<td class="p-2">
<button class="text-left" @click="selected = link">{{ link.name }}</button>
</td>
<td class="p-2">{{ link.owner }}</td>
<td class="p-2">{{ link.username || "—" }}</td>
<td class="p-2 break-all">{{ link.host }}</td>
</tr>
</tbody>
</table>
<p v-if="!busy && !filtered.length" class="p-4 text-sm text-muted-foreground">{{ t("databaseLinks.empty") }}</p>
</div>
<template v-if="selected && !mode">
<p class="text-sm break-all">{{ selected.name }} · {{ selected.owner }} · {{ selected.created }}</p>
<p v-if="selectedIsShadowed" class="text-sm text-muted-foreground">{{ t("databaseLinks.shadowed") }}</p>
<div class="flex gap-2">
<Button variant="outline" :disabled="busy || selectedIsShadowed" @click="test">{{ t("databaseLinks.test") }}</Button
><Button variant="outline" :disabled="busy || !selected.username" @click="begin('alter')">{{ t("databaseLinks.alter") }}</Button
><Button variant="destructive" :disabled="busy" @click="begin('drop')">{{ t("common.delete") }}</Button>
</div>
</template>
<form v-if="mode" class="space-y-3 border-t pt-3" @submit.prevent="save">
<template v-if="mode === 'create'">
<label class="block text-sm">{{ t("databaseLinks.name") }}<Input v-model="name" required autocomplete="off" /></label>
<label class="block text-sm">{{ t("databaseLinks.username") }}<Input v-model="username" required autocomplete="off" /></label>
<label class="block text-sm">{{ t("databaseLinks.host") }}<Input v-model="host" required placeholder="//host:1521/service" autocomplete="off" /></label>
<label class="flex items-center gap-2 text-sm"><input v-model="publicLink" type="checkbox" />{{ t("databaseLinks.public") }}</label>
</template>
<p v-if="mode === 'alter'" class="text-sm">{{ t("databaseLinks.alterHint", { name: selected?.name }) }}</p>
<label v-if="mode !== 'drop'" class="block text-sm">{{ t("databaseLinks.password") }}<Input v-model="password" type="password" required autocomplete="new-password" /></label>
<p v-else class="text-sm">{{ t("databaseLinks.dropConfirm", { name: selected?.name, owner: selected?.owner }) }}</p>
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
:disabled="busy"
@click="
mode = '';
password = '';
"
>{{ t("common.cancel") }}</Button
><Button type="submit" :disabled="busy" :variant="mode === 'drop' ? 'destructive' : 'default'">{{ t(mode === "drop" ? "common.delete" : "common.save") }}</Button>
</div>
</form>
<p v-if="busy" role="status" class="text-sm text-muted-foreground">{{ t("common.loading") }}</p>
<p v-if="error" role="alert" class="text-sm text-destructive whitespace-pre-wrap break-all">{{ error }}</p>
<p v-if="message" role="status" class="text-sm">{{ message }}</p>
</DialogContent>
</Dialog>
</template>
@@ -1008,6 +1008,8 @@ async function toggle(requestId = beginNavigationRequest()) {
await connectionStore.loadDorisCatalogDatabases(node, treeLoadSearchOptions);
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && schemaNodeHasLoadableName(effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)), node.schema)) {
await connectionStore.loadTables(node.connectionId, node.database, node.schema, treeLoadSearchOptions);
} else if (node.type === "oracle-db-links") {
await connectionStore.loadTreeNodeChildren(node, treeLoadSearchOptions);
} else if (node.type === "linked-server-root" && node.connectionId) {
await connectionStore.loadSqlServerLinkedServers(node.connectionId, treeLoadSearchOptions);
} else if (node.type === "linked-server" && node.connectionId) {
@@ -39,6 +39,8 @@ import {
CircleX,
RefreshCw,
} from "@lucide/vue";
import OracleDatabaseLinksDialog from "@/components/objects/OracleDatabaseLinksDialog.vue";
const showDatabaseLinks = ref(false);
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -361,6 +363,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Braces, colorClass: "text-amber-500" };
case "sequence":
return { icon: ListTree, colorClass: "text-emerald-500" };
case "oracle-db-links":
case "oracle-db-link":
case "synonym":
return { icon: Link2, colorClass: "text-sky-500" };
case "job":
@@ -432,7 +436,7 @@ function displayLabel(node: TreeNode): string {
// internationalized; those nodes may still contain the old Chinese text.
if (node.type === "nacos-access-control") return t("nacos.accessControlSidebarLabel");
if (node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "meilisearch-system") return t(node.label);
if (node.type === "linked-server-root") return t(node.label);
if (node.type === "oracle-db-links" || node.type === "linked-server-root") return t(node.label);
if (node.type === "saved-sql-root") return t(node.label);
if (node.type === "mqtt-topic" && node.id.endsWith(":mqtt-topic:__console__")) return t(node.label);
if (node.label === "tree.defaultDatabase") return t(node.label);
@@ -821,7 +825,7 @@ const canExpand = computed(() => {
return canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true,
explicitContainer: activeNode.value.type === "oracle-db-links" || (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true,
});
});
@@ -1454,14 +1458,27 @@ function onClick(event: MouseEvent) {
selectSingleTreeNode(props.node);
rowRef.value?.focus({ preventScroll: true });
if (!shouldActivateTreeNodeOnSingleClick(props.node.type, settingsStore.editorSettings.sidebarActivation) && props.node.type !== "load-more") return;
if (props.node.type === "oracle-db-link") {
showDatabaseLinks.value = true;
return;
}
treeRuntime.handleRowClick(props.node, event.detail);
}
function onDoubleClick(event: MouseEvent) {
if (props.node.type === "oracle-db-link" || props.node.type === "oracle-db-links") {
showDatabaseLinks.value = true;
return;
}
treeRuntime.handleRowDoubleClick(props.node, event);
}
function onTreeItemContextMenu(event: MouseEvent) {
if (props.node.type === "oracle-db-link" || props.node.type === "oracle-db-links") {
event.preventDefault();
showDatabaseLinks.value = true;
return;
}
if (!connectionStore.selectedTreeNodeIds.includes(props.node.id)) selectSingleTreeNode(props.node);
else connectionStore.selectedTreeNodeId = props.node.id;
rowRef.value?.focus({ preventScroll: true });
@@ -1469,6 +1486,11 @@ function onTreeItemContextMenu(event: MouseEvent) {
}
function onKeydown(event: KeyboardEvent) {
if ((props.node.type === "oracle-db-link" || props.node.type === "oracle-db-links") && event.key === "Enter") {
event.preventDefault();
showDatabaseLinks.value = true;
return;
}
treeRuntime.handleRowKeydown(props.node, event);
}
</script>
@@ -1579,6 +1601,9 @@ function onKeydown(event: KeyboardEvent) {
]"
>{{ visibleLabel(node) }}</span
>
<button v-if="node.type === 'oracle-db-links'" class="ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted" :aria-label="t('databaseLinks.manage')" :title="t('databaseLinks.manage')" @click.stop="showDatabaseLinks = true" @dblclick.stop>
<TableProperties class="h-3.5 w-3.5" />
</button>
<span v-if="treeNodeSecondaryValue(node)" class="flex min-w-0 max-w-[55%] shrink items-center gap-1 text-xs text-muted-foreground" :title="node.type === 'elasticsearch-index' ? undefined : treeNodeSecondaryValue(node)">
<Link2 v-if="node.type === 'elasticsearch-index'" class="h-3 w-3 shrink-0 text-sky-400" />
<span class="min-w-0 truncate">{{ treeNodeSecondaryValue(node) }}</span>
@@ -1718,6 +1743,15 @@ function onKeydown(event: KeyboardEvent) {
</template>
</LightTooltip>
</div>
<OracleDatabaseLinksDialog
v-if="showDatabaseLinks && node.connectionId"
v-model:open="showDatabaseLinks"
:connection-id="node.connectionId"
:database="node.database || ''"
:name="node.type === 'oracle-db-link' ? node.label : undefined"
:owner="node.schema"
@changed="connectionStore.refreshOracleDatabaseLinks(node.connectionId)"
/>
</template>
<style>