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

This commit is contained in:
zipg
2026-09-13 02:00:09 +08:00
committed by GitHub
parent 6694e4382e
commit b8f39f9c24
25 changed files with 645 additions and 13 deletions
@@ -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>
+20
View File
@@ -3619,6 +3619,25 @@ export default {
emptySelection: "Select at least one schema, or use Show all to clear the filter.",
loadFailed: "Failed to load schemas: {message}",
},
databaseLinks: {
shadowed: "A private link with the same name takes precedence in this session. Test the public link from a user without that private link.",
search: "Search database links",
name: "Link name",
owner: "Owner",
username: "Remote user",
host: "Connect string",
password: "Remote password",
public: "Public link (requires CREATE PUBLIC DATABASE LINK)",
create: "Create link",
alter: "Update credentials",
test: "Test link",
testSuccess: "Database link query succeeded.",
empty: "No accessible database links.",
saved: "Database link updated.",
alterHint: "Update the stored remote password for {name}. To change the user or connect string, create a new link.",
dropConfirm: "Delete database link {owner} / {name}? This does not delete remote data.",
manage: "Manage database links",
},
tree: {
savedSql: "SQL Library",
queries: "Queries",
@@ -3669,6 +3688,7 @@ export default {
functions: "Functions",
sequences: "Sequences",
schedulerJobs: "Scheduled Jobs",
databaseLinks: "Database Links",
synonyms: "Synonyms",
packages: "Packages",
types: "Types",
+20
View File
@@ -3501,7 +3501,27 @@ export default withEnglishFallback({
emptySelection: "Selecciona al menos un schema, o usa Mostrar todo para quitar el filtro.",
loadFailed: "No se pudieron cargar los schemas: {message}",
},
databaseLinks: {
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
owner: "Owner",
username: "Remote user",
host: "Connect string",
password: "Remote password",
public: "Public link (requires CREATE PUBLIC DATABASE LINK)",
create: "Create link",
alter: "Update credentials",
test: "Test link",
testSuccess: "Database link query succeeded.",
empty: "No accessible database links.",
saved: "Database link updated.",
alterHint: "Update the stored remote password for {name}. To change the user or connect string, create a new link.",
dropConfirm: "Delete database link {owner} / {name}? This does not delete remote data.",
manage: "Manage database links",
},
tree: {
databaseLinks: "Database Links",
savedSql: "Biblioteca SQL",
queries: "Consultas",
userAdmin: "Usuarios y Privilegios",
+20
View File
@@ -3499,7 +3499,27 @@ export default withEnglishFallback({
emptySelection: "Seleziona almeno uno schema, oppure usa Mostra tutto per rimuovere il filtro.",
loadFailed: "Impossibile caricare gli schema: {message}",
},
databaseLinks: {
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
owner: "Owner",
username: "Remote user",
host: "Connect string",
password: "Remote password",
public: "Public link (requires CREATE PUBLIC DATABASE LINK)",
create: "Create link",
alter: "Update credentials",
test: "Test link",
testSuccess: "Database link query succeeded.",
empty: "No accessible database links.",
saved: "Database link updated.",
alterHint: "Update the stored remote password for {name}. To change the user or connect string, create a new link.",
dropConfirm: "Delete database link {owner} / {name}? This does not delete remote data.",
manage: "Manage database links",
},
tree: {
databaseLinks: "Database Links",
savedSql: "Libreria SQL",
queries: "Query",
userAdmin: "Utenti e Privilegi",
+20
View File
@@ -3525,7 +3525,27 @@ export default withEnglishFallback({
emptySelection: "少なくとも1つのスキーマを選択するか、すべて表示でフィルターをクリアしてください。",
loadFailed: "スキーマの読み込みに失敗しました: {message}",
},
databaseLinks: {
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
owner: "Owner",
username: "Remote user",
host: "Connect string",
password: "Remote password",
public: "Public link (requires CREATE PUBLIC DATABASE LINK)",
create: "Create link",
alter: "Update credentials",
test: "Test link",
testSuccess: "Database link query succeeded.",
empty: "No accessible database links.",
saved: "Database link updated.",
alterHint: "Update the stored remote password for {name}. To change the user or connect string, create a new link.",
dropConfirm: "Delete database link {owner} / {name}? This does not delete remote data.",
manage: "Manage database links",
},
tree: {
databaseLinks: "Database Links",
savedSql: "SQLライブラリ",
queries: "クエリ",
userAdmin: "ユーザーと権限",
+20
View File
@@ -3378,7 +3378,27 @@ export default withEnglishFallback({
emptySelection: "최소 한 개의 스키마를 선택하거나, 모두 표시를 사용해 필터를 지우세요.",
loadFailed: "스키마를 불러오는 데 실패했습니다: {message}",
},
databaseLinks: {
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
owner: "Owner",
username: "Remote user",
host: "Connect string",
password: "Remote password",
public: "Public link (requires CREATE PUBLIC DATABASE LINK)",
create: "Create link",
alter: "Update credentials",
test: "Test link",
testSuccess: "Database link query succeeded.",
empty: "No accessible database links.",
saved: "Database link updated.",
alterHint: "Update the stored remote password for {name}. To change the user or connect string, create a new link.",
dropConfirm: "Delete database link {owner} / {name}? This does not delete remote data.",
manage: "Manage database links",
},
tree: {
databaseLinks: "Database Links",
savedSql: "SQL 라이브러리",
queries: "쿼리",
userAdmin: "사용자 및 권한",
+20
View File
@@ -3501,7 +3501,27 @@ export default withEnglishFallback({
emptySelection: "Selecione pelo menos um schema, ou use Mostrar todos para limpar o filtro.",
loadFailed: "Falha ao carregar schemas: {message}",
},
databaseLinks: {
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
owner: "Owner",
username: "Remote user",
host: "Connect string",
password: "Remote password",
public: "Public link (requires CREATE PUBLIC DATABASE LINK)",
create: "Create link",
alter: "Update credentials",
test: "Test link",
testSuccess: "Database link query succeeded.",
empty: "No accessible database links.",
saved: "Database link updated.",
alterHint: "Update the stored remote password for {name}. To change the user or connect string, create a new link.",
dropConfirm: "Delete database link {owner} / {name}? This does not delete remote data.",
manage: "Manage database links",
},
tree: {
databaseLinks: "Database Links",
savedSql: "Biblioteca SQL",
queries: "Consultas",
userAdmin: "Usuários e Privilégios",
+20
View File
@@ -3540,6 +3540,25 @@ export default withEnglishFallback({
emptySelection: "至少选择一个 Schema,或使用「显示全部」清除过滤。",
loadFailed: "加载 Schema 失败:{message}",
},
databaseLinks: {
shadowed: "当前会话会优先使用同名私有链接。请使用没有该私有链接的用户测试此公有链接。",
search: "搜索数据库链接",
name: "链接名称",
owner: "所有者",
username: "远程用户",
host: "连接字符串",
password: "远程密码",
public: "公有链接(需要 CREATE PUBLIC DATABASE LINK 权限)",
create: "新建链接",
alter: "更新凭据",
test: "测试链接",
testSuccess: "数据库链接查询成功。",
empty: "没有可访问的数据库链接。",
saved: "数据库链接已更新。",
alterHint: "更新 {name} 保存的远程密码。修改用户或连接字符串请新建链接。",
dropConfirm: "删除数据库链接 {owner} / {name}?此操作不会删除远程数据。",
manage: "管理数据库链接",
},
tree: {
savedSql: "SQL 库",
queries: "查询",
@@ -3590,6 +3609,7 @@ export default withEnglishFallback({
functions: "函数",
sequences: "序列",
schedulerJobs: "定时作业",
databaseLinks: "数据库链接",
synonyms: "同义词",
packages: "包",
types: "类型",
+20
View File
@@ -3498,7 +3498,27 @@ export default withEnglishFallback({
emptySelection: "至少選擇一個 Schema,或使用「顯示全部」清除篩選。",
loadFailed: "載入 Schema 失敗:{message}",
},
databaseLinks: {
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
owner: "Owner",
username: "Remote user",
host: "Connect string",
password: "Remote password",
public: "Public link (requires CREATE PUBLIC DATABASE LINK)",
create: "Create link",
alter: "Update credentials",
test: "Test link",
testSuccess: "Database link query succeeded.",
empty: "No accessible database links.",
saved: "Database link updated.",
alterHint: "Update the stored remote password for {name}. To change the user or connect string, create a new link.",
dropConfirm: "Delete database link {owner} / {name}? This does not delete remote data.",
manage: "Manage database links",
},
tree: {
databaseLinks: "Database Links",
savedSql: "SQL 庫",
queries: "查詢",
userAdmin: "使用者與權限",
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { ORACLE_DATABASE_LINKS_SQL, createOracleDatabaseLinkSql, alterOracleDatabaseLinkSql, dropOracleDatabaseLinkSql, oracleDatabaseLinkName } from "@/lib/database/oracleDatabaseLinks";
import { oracleDatabaseLinkCompletionContext as context, oracleDatabaseLinkCompletionItems as items } from "@/lib/sql/oracleDatabaseLinkCompletion";
const privateLink = { owner: "APP", name: "REMOTE.EXAMPLE.COM", username: "REMOTE_USER", host: "//localhost:1521/XE", created: "" };
describe("Oracle database links", () => {
it.each(["SELECT * FROM DUAL@", "SELECT * FROM APP.DUAL@", 'SELECT * FROM "Odd table"@', "UPDATE APP.T@", "BEGIN APP.PROC@", "INSERT INTO T@", "DELETE FROM T@", "SELECT * FROM A JOIN B@"])("offers suffix completion in %s", (sql) => {
expect(context(sql, sql.length, "oracle")).toEqual({ prefix: "", from: sql.length, to: sql.length });
});
it("replaces an entire domain suffix, including text after the cursor", () => {
const sql = "SELECT * FROM DUAL@REMOTE.EXAMPLE.COM";
const cursor = sql.indexOf("EXAMPLE");
expect(context(sql, cursor, "oracle")).toEqual({ prefix: "REMOTE.", from: sql.indexOf("REMOTE"), to: sql.length });
});
it.each(["SELECT '@", "-- DUAL@", "/* DUAL@", "SELECT @", "SELECT @@", "SELECT * FROM DUAL@@"])("suppresses non-link context %s", (sql) => expect(context(sql, sql.length, "oracle")).toBeNull());
it("does not affect other dialects", () => expect(context("SELECT T@", 9, "mysql")).toBeNull());
it("deduplicates public links shadowed by a private link and never inserts owner qualification", () => {
expect(items([{ ...privateLink, owner: "PUBLIC" }, privateLink], "remote.")).toEqual([{ label: privateLink.name, apply: privateLink.name, type: "namespace", detail: "APP · REMOTE_USER · //localhost:1521/XE" }]);
});
it("keeps the login user's private links visible regardless of the editor's current schema", () => {
const publicLink = { ...privateLink, owner: "PUBLIC", name: "PUBLIC.EXAMPLE.COM" };
expect(items([privateLink, publicLink], "")).toHaveLength(2);
expect(items([privateLink], "remote.")).toHaveLength(1);
expect(items([privateLink], "other.")).toEqual([]);
});
it("uses the login user's visibility independently of current schema", () => {
expect(ORACLE_DATABASE_LINKS_SQL).toContain("SESSION_USER");
expect(ORACLE_DATABASE_LINKS_SQL).toContain("'PUBLIC'");
expect(ORACLE_DATABASE_LINKS_SQL).not.toContain("CURRENT_SCHEMA");
});
it("creates fixed-user links with an escaped connect string", () => {
expect(createOracleDatabaseLinkSql({ name: privateLink.name, username: "REMOTE_USER", password: "p@ss", host: "test'host", public: false })).toBe(`CREATE DATABASE LINK REMOTE.EXAMPLE.COM CONNECT TO "REMOTE_USER" IDENTIFIED BY "p@ss" USING 'test''host'`);
});
it("updates the remote credential without redefining the link", () => expect(alterOracleDatabaseLinkSql(privateLink, "new-pass")).toBe('ALTER DATABASE LINK REMOTE.EXAMPLE.COM CONNECT TO "REMOTE_USER" IDENTIFIED BY "new-pass"'));
it("preserves a case-sensitive remote user read from metadata", () => expect(alterOracleDatabaseLinkSql({ ...privateLink, username: "MixedCase" }, "new-pass")).toContain('CONNECT TO "MixedCase"'));
it("normalizes unquoted usernames when creating links", () => expect(createOracleDatabaseLinkSql({ name: "REMOTE", username: "remote_user", password: "pass", host: "XE", public: false })).toContain('CONNECT TO "REMOTE_USER"'));
it("drops public links with the public modifier", () => expect(dropOracleDatabaseLinkSql({ ...privateLink, owner: "PUBLIC" })).toBe("DROP PUBLIC DATABASE LINK REMOTE.EXAMPLE.COM"));
it.each(["x; DROP TABLE t", "x--", 'x"', "a b", "a..b"])("rejects unsafe link names %s", (name) => expect(() => oracleDatabaseLinkName(name)).toThrow());
});
@@ -0,0 +1,57 @@
import type { QueryResult } from "@/types/database";
export interface OracleDatabaseLink {
name: string;
owner: string;
username: string;
host: string;
created: string;
}
// Links belong to the login user, independently of ALTER SESSION SET CURRENT_SCHEMA.
// Do not expose another user's private links when connected with catalog privileges.
export const ORACLE_DATABASE_LINKS_SQL = `SELECT OWNER, DB_LINK, USERNAME, HOST,
TO_CHAR(CREATED, 'YYYY-MM-DD HH24:MI:SS') AS CREATED
FROM ALL_DB_LINKS
WHERE OWNER IN (SYS_CONTEXT('USERENV', 'SESSION_USER'), 'PUBLIC')
ORDER BY DB_LINK, CASE WHEN OWNER = 'PUBLIC' THEN 1 ELSE 0 END`;
export function oracleDatabaseLinksFromResult(result: QueryResult): OracleDatabaseLink[] {
const indexes = new Map(result.columns.map((name, index) => [name.toUpperCase(), index]));
return result.rows
.map((row) => {
const value = (name: string) => String(row[indexes.get(name) ?? -1] ?? "");
return { name: value("DB_LINK"), owner: value("OWNER"), username: value("USERNAME"), host: value("HOST"), created: value("CREATED") };
})
.filter((link) => !!link.name && !!link.owner);
}
export function oracleDatabaseLinkName(name: string): string {
// Oracle link names are ASCII, case insensitive, and may include a domain.
// A dot is part of the link name, never a schema qualifier.
if (!/^[A-Za-z][A-Za-z0-9_$#]*(?:\.[A-Za-z0-9_$#]+)*$/.test(name) || name.length > 128) throw new Error("Invalid database link name");
return name;
}
function oracleQuotedIdentifier(value: string, fromMetadata = false): string {
if (!value || /[\0\r\n]/.test(value)) throw new Error("Invalid identifier");
const name = fromMetadata ? value : value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replaceAll('""', '"') : value.toUpperCase();
return `"${name.replaceAll('"', '""')}"`;
}
export function createOracleDatabaseLinkSql(input: { name: string; public: boolean; username: string; password: string; host: string }): string {
if (!input.host.trim() || !input.password || /[\0\r\n"]/.test(input.password)) throw new Error("A connect string and a password without double quotes or line breaks are required");
return `CREATE ${input.public ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(input.name)} CONNECT TO ${oracleQuotedIdentifier(input.username)} IDENTIFIED BY "${input.password}" USING '${input.host.replaceAll("'", "''")}'`;
}
export function alterOracleDatabaseLinkSql(link: OracleDatabaseLink, password: string): string {
if (!link.username || !password || /[\0\r\n"]/.test(password)) throw new Error("A fixed-user link and a password without double quotes or line breaks are required");
return `ALTER ${link.owner === "PUBLIC" ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(link.name)} CONNECT TO ${oracleQuotedIdentifier(link.username, true)} IDENTIFIED BY "${password}"`;
}
export function dropOracleDatabaseLinkSql(link: OracleDatabaseLink): string {
return `DROP ${link.owner === "PUBLIC" ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(link.name)}`;
}
export function testOracleDatabaseLinkSql(link: OracleDatabaseLink): string {
return `SELECT 1 AS DBX_LINK_OK FROM DUAL@${oracleDatabaseLinkName(link.name)}`;
}
@@ -632,6 +632,12 @@ const FUNCTION_ICON: LucideIconNode = [
["path", { d: "M4 4v7a4 4 0 0 0 4 4h12" }],
];
const DATABASE_LINK_ICON: LucideIconNode = [
["path", { d: "M9 17H7A5 5 0 0 1 7 7h2" }],
["path", { d: "M15 7h2a5 5 0 0 1 0 10h-2" }],
["path", { d: "M8 12h8" }],
];
const SCHEMA_ICON: LucideIconNode = [["path", { d: "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v11z" }]];
function encodeSvgIcon(iconNode: LucideIconNode): string {
@@ -1067,6 +1073,10 @@ export function buildSqlCompletionThemeRules(): CodeMirrorStyleSpec {
color: colorMixValue("var(--emerald-500, #10b981)", "color-mix(in oklch, var(--emerald-500, #10b981) 92%, var(--popover-foreground))"),
...lucideCompletionIconMask(FUNCTION_ICON),
},
".cm-completionIcon-namespace": {
color: colorMixValue("var(--sky-500, #0ea5e9)", "color-mix(in oklch, var(--sky-500, #0ea5e9) 92%, var(--popover-foreground))"),
...lucideCompletionIconMask(DATABASE_LINK_ICON),
},
".cm-completionIcon-schema": {
color: colorMixValue("var(--amber-500, #f59e0b)", "color-mix(in oklch, var(--amber-500, #f59e0b) 92%, var(--popover-foreground))"),
...lucideCompletionIconMask(SCHEMA_ICON),
@@ -9,6 +9,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"procedure",
"function",
"synonym",
"oracle-db-link",
"package",
"package-body",
"type-body",
@@ -1,6 +1,7 @@
import type { TreeNodeType } from "@/types/database";
const treeGroupNodeTypes = new Set<TreeNodeType>([
"oracle-db-links",
"group-columns",
"group-indexes",
"group-fkeys",
@@ -114,6 +114,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | 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":
@@ -0,0 +1,28 @@
import type { DatabaseType } from "@/types/database";
import type { OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks";
import { isSqlCompletionSuppressedContext } from "@/lib/sql/sqlCompletion";
export function oracleDatabaseLinkCompletionContext(sql: string, cursor: number, databaseType?: DatabaseType) {
if (databaseType !== "oracle" || isSqlCompletionSuppressedContext(sql, cursor, { databaseType })) return null;
const before = sql.slice(0, cursor);
const match = /(?:[A-Za-z0-9_$#]|"(?:[^"]|"")+"|\))@([A-Za-z0-9_$#.]*)$/.exec(before);
if (!match) return null;
const prefix = match[1];
return { prefix, from: cursor - prefix.length, to: cursor + (/^[A-Za-z0-9_$#.]*/.exec(sql.slice(cursor))?.[0].length ?? 0) };
}
export function oracleDatabaseLinkCompletionItems(links: readonly OracleDatabaseLink[], prefix: string) {
const seen = new Set<string>();
// Oracle resolves object@link against the login user's private links plus
// PUBLIC links; CURRENT_SCHEMA never enables or disables either, and the
// link query already restricts owners to SESSION_USER and PUBLIC.
return [...links]
.sort((a, b) => Number(a.owner === "PUBLIC") - Number(b.owner === "PUBLIC"))
.filter((link) => {
const key = link.name.toUpperCase();
if (seen.has(key) || !key.startsWith(prefix.toUpperCase())) return false;
seen.add(key);
return true;
})
.map((link) => ({ label: link.name, apply: link.name, type: "namespace", detail: `${link.owner} · ${link.username || "—"} · ${link.host}` }));
}
@@ -647,7 +647,15 @@ describe("connectionStore metadata loading", () => {
const listTables = vi.fn().mockResolvedValue([{ name: "sheet", table_type: "TABLE", comment: null }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
const executeQuery = vi.fn().mockResolvedValue({
columns: ["OWNER", "DB_LINK", "USERNAME", "HOST", "CREATED"],
rows: [
["DBX_TEST", "PRIVATE.EXAMPLE", "REMOTE", "service", ""],
["PUBLIC", "PUBLIC.EXAMPLE", "REMOTE", "service", ""],
],
});
vi.doMock("@/lib/backend/api", () => ({
executeQuery,
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases,
@@ -674,7 +682,23 @@ describe("connectionStore metadata loading", () => {
expect(listDatabases).not.toHaveBeenCalled();
expect(listSchemas).toHaveBeenCalledWith(connection.id, "");
expect(connectionNode.children?.map((node) => [node.type, node.label, node.database, node.schema])).toEqual([["schema", "DBX_TEST", "DBX_TEST", "DBX_TEST"]]);
expect(connectionNode.children?.map((node) => [node.type, node.label, node.database, node.schema])).toEqual([
["schema", "DBX_TEST", "DBX_TEST", "DBX_TEST"],
["oracle-db-links", "tree.databaseLinks", "", undefined],
]);
const linkRoot = connectionNode.children!.find((node) => node.type === "oracle-db-links")!;
await store.loadTreeNodeChildren(linkRoot, { force: true });
expect(executeQuery).toHaveBeenCalledWith(connection.id, "", expect.stringContaining("SESSION_USER"), undefined, undefined, { maxRows: 10000, timeoutSecs: 15 });
expect(linkRoot.children?.map((node) => [node.type, node.label, node.schema])).toEqual([
["oracle-db-link", "PRIVATE.EXAMPLE", "DBX_TEST"],
["oracle-db-link", "PUBLIC.EXAMPLE", "PUBLIC"],
]);
expect(linkRoot.objectCount).toBe(2);
executeQuery.mockResolvedValue({ columns: ["OWNER", "DB_LINK"], rows: [] });
await store.refreshOracleDatabaseLinks(connection.id);
expect(linkRoot.children).toEqual([]);
expect(linkRoot.objectCount).toBe(0);
const schemaNode = connectionNode.children?.[0];
expect(schemaNode).toBeDefined();
+50 -2
View File
@@ -80,6 +80,7 @@ import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject,
import { usesOracleCurrentSchemaCompletion } from "@/lib/sql/oracleCompletionSession";
import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import * as api from "@/lib/backend/api";
import { ORACLE_DATABASE_LINKS_SQL, oracleDatabaseLinksFromResult } from "@/lib/database/oracleDatabaseLinks";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, schemaNodeHasLoadableName, shouldShowDorisCatalogTree, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
@@ -1505,7 +1506,7 @@ export const useConnectionStore = defineStore("connection", () => {
// as metadata children, withConnectionUtilityNodes would keep the old copies
// AND append fresh ones on every useCachedChildren pass, duplicating the
// 用户/角色 menus once per refresh cycle.
return node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "group-tablespaces" || node.type === "saved-sql-root";
return node.type === "oracle-db-links" || node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "group-tablespaces" || node.type === "saved-sql-root";
}
function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] {
@@ -1818,6 +1819,49 @@ export const useConnectionStore = defineStore("connection", () => {
};
}
function buildOracleDatabaseLinksNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
const config = getConfig(connectionId);
if (effectiveDatabaseTypeForConnection(config) !== "oracle") return undefined;
const existing = existingConnectionNode?.children?.find((child) => child.type === "oracle-db-links");
return { ...existing, id: `${connectionId}:__oracle_db_links`, label: "tree.databaseLinks", type: "oracle-db-links", connectionId, database: config?.database || "", isExpanded: existing?.isExpanded ?? false, children: existing?.children ?? [] };
}
async function listOracleDatabaseLinks(connectionId: string, database: string) {
if (effectiveDatabaseTypeForConnection(getConfig(connectionId)) !== "oracle") return [];
await ensureConnected(connectionId);
return oracleDatabaseLinksFromResult(await api.executeQuery(connectionId, database, ORACLE_DATABASE_LINKS_SQL, undefined, undefined, { maxRows: 10000, timeoutSecs: 15 }));
}
async function refreshOracleDatabaseLinks(connectionId: string) {
const root = findNode(treeNodes.value, `${connectionId}:__oracle_db_links`);
if (root) await loadOracleDatabaseLinks(root, { force: true });
}
async function loadOracleDatabaseLinks(node: TreeNode, options?: LoadTreeOptions) {
if (!node.connectionId) return;
await runConnectionTreeMetadataLoad(node.connectionId, node, async (load) => {
if (useCachedChildren(node, options, load)) return;
const links = await listOracleDatabaseLinks(node.connectionId!, node.database || "");
const target = treeNodeLoadTarget(load);
if (!target) return;
setChildren(
target,
links.map((link) => ({
id: `${node.id}:${encodeURIComponent(link.owner)}:${encodeURIComponent(link.name)}`,
label: link.name,
type: "oracle-db-link" as const,
connectionId: node.connectionId,
database: node.database,
schema: link.owner,
comment: `${link.owner} · ${link.username} · ${link.host}`,
isExpanded: false,
})),
);
target.objectCount = links.length;
target.isExpanded = true;
});
}
function withConnectionUtilityNodes(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
const nonUtilityChildren = connectionMetadataChildren(children);
const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode);
@@ -1825,7 +1869,7 @@ export const useConnectionStore = defineStore("connection", () => {
const damengRoleNode = buildDamengRoleNode(connectionId, existingConnectionNode);
const damengJobAdminNode = buildDamengJobAdminNode(connectionId, existingConnectionNode);
const xuguTablespacesNode = buildXuguTablespacesNode(connectionId, existingConnectionNode);
return [...nonUtilityChildren, userAdminNode, damengUserNode, damengRoleNode, damengJobAdminNode, xuguTablespacesNode].filter(Boolean) as TreeNode[];
return [...nonUtilityChildren, buildOracleDatabaseLinksNode(connectionId, existingConnectionNode), userAdminNode, damengUserNode, damengRoleNode, damengJobAdminNode, xuguTablespacesNode].filter(Boolean) as TreeNode[];
}
function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
@@ -6743,6 +6787,8 @@ export const useConnectionStore = defineStore("connection", () => {
} else {
await loadDatabases(node.connectionId, options);
}
} else if (node.type === "oracle-db-links") {
await loadOracleDatabaseLinks(node, options);
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
await loadMongoCollections(node.connectionId, node.database);
} else if (node.type === "vector-database" && node.connectionId && node.database) {
@@ -9103,6 +9149,8 @@ export const useConnectionStore = defineStore("connection", () => {
loadMoreObjectGroupChildren,
loadAllObjectGroupChildren,
loadTableGroups,
listOracleDatabaseLinks,
refreshOracleDatabaseLinks,
loadTreeNodeChildren,
loadColumns,
loadIndexes,
+2
View File
@@ -944,6 +944,8 @@ export type TreeNodeType =
| "group-types"
| "group-sequences"
| "group-synonyms"
| "oracle-db-links"
| "oracle-db-link"
| "group-jobs"
| "group-packages"
| "group-partitions"
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB