mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(oracle): add dblink completion, tree view and management
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ユーザーと権限",
|
||||
|
||||
@@ -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: "사용자 및 권한",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "类型",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user