feat(oceanbase-oracle): support sequences, synonyms and db links

This commit is contained in:
efeire authored and GitHub committed 2026-09-20 00:49:44 +08:00
1 parent a19a4f595d
commit 04376ef3fb
31 files changed
+1225 -91

No files matched your search

+1
View File
@@ -2821,6 +2821,7 @@ function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditing: bo
title: `Source - ${sourceName}`,
schema: sourceSchema || target.database,
catalog: target.catalog,
initialEditing,
request: { name: sourceName, objectType, signature: navigation.signature },
});
return;
@@ -1457,7 +1457,7 @@ async function openSource(row: ObjectBrowserRow) {
try {
const result = await api.getObjectSource(connectionId, database, schema, row.name, row.type as ObjectSourceKind, row.signature ?? undefined);
if (sidePanelGuard.isStale(epoch)) return;
sourceCanEdit.value = result.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(row.type);
sourceCanEdit.value = result.editable !== false && !["TRIGGER", "TYPE", "TYPE_BODY"].includes(row.type) && (row.type !== "SEQUENCE" || effectiveDatabaseType.value === "oceanbase-oracle");
const editable = sourceCanEdit.value
? await api.buildEditableObjectSource({
databaseType: effectiveDatabaseType.value,
@@ -1471,9 +1471,9 @@ async function openSource(row: ObjectBrowserRow) {
// Viewing database source must preserve its original whitespace and comments;
// formatting remains an explicit editor action instead of altering it on open.
sourceEditableText.value = editable;
sourceContent.value = editable;
sourceContent.value = row.type === "SEQUENCE" ? result.source : editable;
sourceDraft.value = editable;
sourceEditing.value = sourceCanEdit.value;
sourceEditing.value = sourceCanEdit.value && row.type !== "SEQUENCE";
if (!sourceCanEdit.value && row.type !== "SEQUENCE") {
toast(t("objects.sourceReadOnly"), 3000);
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Clipboard, Loader2, PencilLine, RefreshCw } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
@@ -61,21 +61,27 @@ const routineMetadata = ref<XuguRoutineMetadata | null>(null);
/** May differ from props.objectType after PROCEDURE/FUNCTION/PACKAGE fallback resolution. */
const resolvedObjectType = ref<ObjectSourceKind>(props.objectType);
let loadSerial = 0;
onBeforeUnmount(() => {
++loadSerial;
});
const canEdit = computed(() => sourceEditable.value && props.objectType !== "SEQUENCE");
const canEdit = computed(() => sourceEditable.value && (props.objectType !== "SEQUENCE" || props.databaseType === "oceanbase-oracle"));
const title = computed(() => `${editing.value ? t("contextMenu.editView") : t("contextMenu.viewSource")} - ${props.name}`);
const hasRoutineMetadata = computed(() => !!routineMetadata.value && (routineMetadata.value.parameters.length > 0 || !!routineMetadata.value.returnType));
watch(
() => [props.open, props.connectionId, props.database, props.schema, props.name, props.relationName, props.signature, props.objectType, props.initialEditing] as const,
() => [props.open, props.connectionId, props.database, props.schema, props.name, props.relationName, props.signature, props.objectType, props.databaseType, props.initialEditing] as const,
() => {
if (props.open) void loadSource();
else ++loadSerial;
},
{ immediate: true },
{ immediate: true, flush: "sync" },
);
async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
const serial = ++loadSerial;
const target = { ...props };
saving.value = false;
content.value = "";
editableText.value = "";
draft.value = "";
@@ -86,26 +92,30 @@ async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
editing.value = false;
loading.value = true;
try {
if (!props.databaseType) throw new Error("Connection type is unavailable.");
if (!target.databaseType) throw new Error("Connection type is unavailable.");
// issue #9035:连接建立也纳入弹窗的加载态。调用方不再先 await ensureConnected
// 才打开弹窗,否则弹窗挂载前会有一段没有任何反馈的等待。
await connectionStore.ensureConnected(props.connectionId);
connectionStore.activeConnectionId = props.connectionId;
const schema = props.schema || props.database;
const { source: result, objectType: resolvedType } = await loadObjectSourceWithRoutineFallback(api.getObjectSource, props.connectionId, props.database, schema, props.name, props.objectType, props.signature, props.relationName);
await connectionStore.ensureConnected(target.connectionId);
if (serial !== loadSerial) return;
connectionStore.activeConnectionId = target.connectionId;
const schema = target.schema || target.database;
const { source: result, objectType: resolvedType } = await loadObjectSourceWithRoutineFallback(api.getObjectSource, target.connectionId, target.database, schema, target.name, target.objectType, target.signature, target.relationName);
if (serial !== loadSerial) return;
const editableAllowed = result.editable !== false;
const editable = await buildEditableObjectSource({
databaseType: props.databaseType,
databaseType: target.databaseType,
objectType: resolvedType,
schema,
name: props.name,
name: target.name,
source: result.source,
});
if (serial !== loadSerial) return;
resolvedObjectType.value = resolvedType;
routineMetadata.value = props.databaseType === "xugu" && (resolvedType === "PROCEDURE" || resolvedType === "FUNCTION") ? xuguRoutineMetadataFromDefinition(result.source) : null;
sourceEditable.value = editableAllowed;
const formatted = await formatSqlForDisplay(editable, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
const displaySource = resolvedType === "SEQUENCE" ? result.source : editable;
const formatted = await formatSqlForDisplay(displaySource, target.formatDialect ?? target.dialect, settingsStore.editorSettings.sqlFormatter);
if (serial !== loadSerial) return;
editableText.value = editable;
content.value = formatted;
draft.value = nextEditing && canEdit.value ? resolveObjectSourceEditDraft(props.databaseType, resolvedType, formatted, editable) : "";
@@ -147,46 +157,55 @@ function cancelEditSource() {
}
async function saveSource() {
if (saving.value || loading.value || !props.open) return;
if (!canEdit.value) {
toast(t("objects.sourceReadOnly"), 3000);
return;
}
if (!draft.value.trim() || !props.databaseType) return;
const databaseType = props.databaseType;
const connectionId = props.connectionId,
database = props.database,
name = props.name;
const objectType = resolvedObjectType.value,
serial = loadSerial;
const schema = props.schema || props.database;
saving.value = true;
saveError.value = "";
try {
const statements = await buildExecutableObjectSourceStatements({
databaseType,
objectType: resolvedObjectType.value,
objectType,
schema,
name: props.name,
name,
source: draft.value,
});
if (serial !== loadSerial) return;
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.connectionId),
database: props.database,
connection: connectionStore.getConfig(connectionId),
database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
if (serial !== loadSerial || !props.open) return false;
await executeObjectSourceSave(connectionId, database, databaseType, statements, schema);
return true;
},
});
if (!saved) return;
} else {
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
await executeObjectSourceSave(connectionId, database, databaseType, statements, schema);
}
if (serial !== loadSerial) return;
toast(t("objects.sourceSaved"));
emit("saved");
await loadSource(false);
} catch (e: unknown) {
saveError.value = formatObjectSourceSaveError(e, databaseType, props.objectType, t("objects.postgresViewColumnChangeHint"));
if (serial === loadSerial) saveError.value = formatObjectSourceSaveError(e, databaseType, objectType, t("objects.postgresViewColumnChangeHint"));
} finally {
saving.value = false;
if (serial === loadSerial) saving.value = false;
}
}
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, 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 { alterOracleDatabaseLinkSql, createOracleDatabaseLinkSql, createOceanBaseDatabaseLinkSql, dropOracleDatabaseLinkSql, redactOracleDatabaseLinkError, testOracleDatabaseLinkSql, type OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -12,6 +13,10 @@ const props = defineProps<{ open: boolean; connectionId: string; database: strin
const emit = defineEmits<{ "update:open": [boolean]; changed: [] }>();
const { t } = useI18n();
const store = useConnectionStore();
const oceanbase = computed(() => effectiveDatabaseTypeForConnection(store.getConfig(props.connectionId)) === "oceanbase-oracle");
const tenant = ref("");
const protocol = ref<"OB" | "OCI">("OB");
const cluster = ref("");
const links = ref<OracleDatabaseLink[]>([]);
const sessionUser = ref("");
const selected = ref<OracleDatabaseLink>();
@@ -26,27 +31,38 @@ 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()));
const selectedIsShadowed = computed(() => !oceanbase.value && selected.value?.owner === "PUBLIC" && links.value.some((link) => link.owner !== "PUBLIC" && link.name.toUpperCase() === selected.value?.name.toUpperCase()));
let revision = 0;
onBeforeUnmount(() => {
++revision;
});
watch(
() => [props.open, props.connectionId, props.database],
() => [props.open, props.connectionId, props.database, props.name, props.owner],
() => {
++revision;
mode.value = "";
password.value = "";
selected.value = undefined;
links.value = [];
sessionUser.value = "";
busy.value = false;
error.value = "";
message.value = "";
if (props.open) void reload();
},
{ immediate: true },
{ immediate: true, flush: "sync" },
);
async function reload() {
if (!props.open) return;
const connectionId = props.connectionId,
database = props.database;
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 });
const result = await store.listOracleDatabaseLinks(connectionId, database);
if (requested !== revision) return;
const identity = await api.executeQuery(connectionId, 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;
@@ -58,6 +74,7 @@ async function reload() {
}
}
function begin(next: "create" | "alter" | "drop") {
if (busy.value || !sessionUser.value || !props.open || (next === "alter" && oceanbase.value)) return;
mode.value = next;
password.value = "";
error.value = "";
@@ -67,52 +84,73 @@ function begin(next: "create" | "alter" | "drop") {
username.value = "";
host.value = "";
publicLink.value = false;
tenant.value = "";
protocol.value = "OB";
cluster.value = "";
}
}
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 }) });
const connectionId = props.connectionId,
database = props.database,
schema = sessionUser.value,
requested = revision;
return executeWithProductionSqlGuard({
connection: store.getConfig(connectionId),
database,
sql,
source: t("tree.databaseLinks"),
execute: () => {
if (requested !== revision || !props.open) throw new Error("Database link target changed");
return api.executeQuery(connectionId, database, sql, schema, undefined, { maxRows: 1, timeoutSecs: 15 });
},
});
}
async function save() {
if (busy.value) return;
if (busy.value || !props.open || !sessionUser.value) return;
const requested = revision,
secret = password.value;
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
? oceanbase.value
? createOceanBaseDatabaseLinkSql({ name: name.value.trim(), username: username.value.trim(), password: password.value, host: host.value, public: publicLink.value, tenant: tenant.value, protocol: protocol.value, cluster: protocol.value === "OB" ? cluster.value : undefined })
: createOracleDatabaseLinkSql({ name: name.value.trim(), username: username.value.trim(), password: password.value, host: host.value, public: publicLink.value })
: mode.value === "alter" && selected.value && !oceanbase.value
? alterOracleDatabaseLinkSql(selected.value, password.value)
: mode.value === "drop" && selected.value
? dropOracleDatabaseLinkSql(selected.value)
? dropOracleDatabaseLinkSql(selected.value, oceanbase.value ? "oceanbase-oracle" : "oracle")
: "";
if (!sql) return;
const result = await execute(sql);
if (result === undefined) return;
if (requested !== revision || result === undefined) return;
mode.value = "";
password.value = "";
message.value = t("databaseLinks.saved");
emit("changed");
await reload();
} catch (e) {
error.value = String(e);
if (requested === revision) error.value = redactOracleDatabaseLinkError(e, secret);
} finally {
busy.value = false;
if (requested === revision) busy.value = false;
}
}
async function test() {
if (!selected.value || busy.value || selectedIsShadowed.value) return;
if (!selected.value || busy.value || selectedIsShadowed.value || !props.open) return;
const requested = revision;
busy.value = true;
error.value = "";
message.value = "";
try {
const result = await execute(testOracleDatabaseLinkSql(selected.value));
if (result === undefined) return;
const result = await execute(testOracleDatabaseLinkSql(selected.value, oceanbase.value ? "oceanbase-oracle" : "oracle"));
if (requested !== revision || result === undefined) return;
message.value = t("databaseLinks.testSuccess");
} catch (e) {
error.value = String(e);
if (requested === revision) error.value = String(e);
} finally {
busy.value = false;
if (requested === revision) busy.value = false;
}
}
</script>
@@ -164,18 +202,31 @@ async function test() {
<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>
<p v-if="oceanbase && selected.canDrop === false" class="text-sm text-muted-foreground">{{ t("databaseLinks.oceanbaseSharedReadOnly") }}</p>
<p v-if="oceanbase" class="text-sm text-muted-foreground">{{ t("databaseLinks.oceanbaseRecreateHint") }}</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>
><Button v-if="!oceanbase" variant="outline" :disabled="busy || !selected.username" @click="begin('alter')">{{ t("databaseLinks.alter") }}</Button
><Button variant="destructive" :disabled="busy || selected.canDrop === false" @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>
<label class="block text-sm">{{ t("databaseLinks.host") }}<Input v-model="host" required :placeholder="oceanbase ? 'host:2881 or host:1521/service' : '//host:1521/service'" autocomplete="off" /></label>
<template v-if="oceanbase">
<label class="block text-sm"
>{{ t("databaseLinks.remoteProtocol")
}}<select v-model="protocol" class="w-full rounded-md border p-2">
<option value="OB">OceanBase (OB)</option>
<option value="OCI">Oracle (OCI)</option>
</select></label
>
<label v-if="protocol === 'OB'" class="block text-sm">{{ t("databaseLinks.remoteTenant") }}<Input v-model="tenant" required autocomplete="off" /></label>
<label v-if="protocol === 'OB'" class="block text-sm">{{ t("databaseLinks.remoteCluster") }}<Input v-model="cluster" autocomplete="off" /></label>
</template>
<label class="flex items-center gap-2 text-sm"><input v-model="publicLink" type="checkbox" />{{ t(oceanbase ? "databaseLinks.oceanbasePublic" : "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>
@@ -0,0 +1,121 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useConnectionStore } from "@/stores/connectionStore";
import type { ConnectionConfig, DatabaseType, ObjectSourceKind } from "@/types/database";
import ObjectSourceDialog from "../ObjectSourceDialog.vue";
const ddl = 'CREATE SEQUENCE "APP"."SEQ" START WITH 40 INCREMENT BY 1 NOCACHE;';
const alter = 'ALTER SEQUENCE "APP"."SEQ" INCREMENT BY 1 NOCACHE;';
const mocks = vi.hoisted(() => ({ getObjectSource: vi.fn(), buildEditableObjectSource: vi.fn(), buildExecutableObjectSourceStatements: vi.fn(), executeQuery: vi.fn() }));
// The Rust/backend boundary is mocked; the dialog, CodeMirror editor and shared
// save/production-guard pipeline remain real. Rust builder tests cover the SQL.
vi.mock("@/lib/backend/api", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/lib/backend/api")>()), ...mocks }));
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
vi.restoreAllMocks();
for (const mock of Object.values(mocks)) mock.mockReset();
});
function button(text: string) {
return [...document.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text);
}
function editor() {
const root = document.querySelector<HTMLElement>('[role="dialog"] .cm-editor');
return root ? EditorView.findFromDOM(root) : null;
}
async function mount(options: { databaseType?: DatabaseType; initialEditing?: boolean; editable?: boolean; type?: ObjectSourceKind; source?: string } = {}) {
const databaseType = options.databaseType ?? "oceanbase-oracle",
objectType = options.type ?? "SEQUENCE",
source = options.source ?? ddl;
const pinia = createPinia();
setActivePinia(pinia);
const store = useConnectionStore();
vi.spyOn(store, "ensureConnected").mockResolvedValue(undefined);
vi.spyOn(store, "getConfig").mockReturnValue({ id: "ob", name: "test", db_type: databaseType } as ConnectionConfig);
mocks.getObjectSource.mockResolvedValue({ name: "SEQ", schema: "APP", object_type: objectType, source, editable: options.editable !== false });
mocks.buildEditableObjectSource.mockResolvedValue(objectType === "SEQUENCE" && databaseType === "oceanbase-oracle" ? alter : source);
mocks.buildExecutableObjectSourceStatements.mockResolvedValue([objectType === "SEQUENCE" ? alter : source]);
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [], affected_rows: 0 });
const saved = vi.fn();
const state = reactive({ open: true, connectionId: "ob", database: "service", schema: "APP", name: "SEQ" });
const host = document.createElement("div");
document.body.append(host);
const app = createApp({ render: () => h(ObjectSourceDialog, { ...state, objectType, databaseType, dialect: "mysql", formatDialect: "plsql", initialEditing: options.initialEditing ?? false, onSaved: saved }) });
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanups.push(() => {
app.unmount();
host.remove();
});
await vi.waitFor(() => expect(editor()).not.toBeNull(), { timeout: 10_000 });
return { saved, state };
}
describe("OceanBase sequence source through the shared Oracle object dialog", () => {
it("previews the CREATE DDL read-only and switches to the backend ALTER draft only on edit", async () => {
await mount();
expect(editor()!.state.facet(EditorState.readOnly)).toBe(true);
expect(editor()!.state.doc.toString()).toMatch(/CREATE\s+SEQUENCE/i);
expect(editor()!.state.doc.toString()).toMatch(/START\s+WITH\s+40/i);
expect(mocks.buildEditableObjectSource).toHaveBeenCalledWith({ databaseType: "oceanbase-oracle", objectType: "SEQUENCE", schema: "APP", name: "SEQ", source: ddl });
button("contextMenu.editView")!.click();
await vi.waitFor(() => expect(editor()!.state.facet(EditorState.readOnly)).toBe(false));
expect(editor()!.state.doc.toString()).toBe(alter);
expect(editor()!.state.doc.toString()).not.toContain("START WITH");
});
it("saves only the generated ALTER and reloads the original object", async () => {
const { saved } = await mount({ initialEditing: true });
expect(editor()!.state.doc.toString()).toBe(alter);
button("objects.saveSource")!.click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(mocks.buildExecutableObjectSourceStatements).toHaveBeenCalledWith({ databaseType: "oceanbase-oracle", objectType: "SEQUENCE", schema: "APP", name: "SEQ", source: alter });
expect(mocks.executeQuery).toHaveBeenCalledExactlyOnceWith("ob", "service", alter, "APP");
await vi.waitFor(() => expect(mocks.getObjectSource).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(editor()!.state.facet(EditorState.readOnly)).toBe(true));
});
it.each([false, true])("leaves native Oracle sequences read-only (initialEditing=%s)", async (initialEditing) => {
await mount({ databaseType: "oracle", initialEditing });
expect(editor()!.state.facet(EditorState.readOnly)).toBe(true);
expect(editor()!.state.doc.toString()).toMatch(/CREATE\s+SEQUENCE/i);
expect(button("contextMenu.editView")).toBeUndefined();
expect(button("objects.saveSource")).toBeUndefined();
expect(mocks.executeQuery).not.toHaveBeenCalled();
});
it("honors a driver-declared read-only OceanBase object", async () => {
await mount({ editable: false, initialEditing: true });
expect(editor()!.state.facet(EditorState.readOnly)).toBe(true);
expect(button("contextMenu.editView")).toBeUndefined();
expect(mocks.executeQuery).not.toHaveBeenCalled();
});
it("does not execute a sequence save after the selected connection changes during SQL generation", async () => {
const { saved, state } = await mount({ initialEditing: true });
let complete!: (value: string[]) => void;
const pending = new Promise<string[]>((resolve) => {
complete = resolve;
});
mocks.buildExecutableObjectSourceStatements.mockReturnValueOnce(pending);
button("objects.saveSource")!.click();
await vi.waitFor(() => expect(mocks.buildExecutableObjectSourceStatements).toHaveBeenCalledOnce());
state.connectionId = "another-connection";
await nextTick();
complete([alter]);
await pending;
await new Promise((resolve) => setTimeout(resolve, 20));
expect(mocks.executeQuery).not.toHaveBeenCalled();
expect(saved).not.toHaveBeenCalled();
});
it("keeps synonym replacement on the existing source editor path", async () => {
const source = 'CREATE OR REPLACE SYNONYM "APP"."SEQ" FOR "APP"."TARGET";';
const { saved } = await mount({ type: "SYNONYM", source, initialEditing: true });
expect(editor()!.state.doc.toString()).toBe(source);
button("objects.saveSource")!.click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(mocks.executeQuery).toHaveBeenCalledExactlyOnceWith("ob", "service", source, "APP");
});
});
@@ -0,0 +1,204 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useConnectionStore } from "@/stores/connectionStore";
import { useReadOnlyUnlockStore } from "@/stores/readOnlyUnlockStore";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import OracleDatabaseLinksDialog from "../OracleDatabaseLinksDialog.vue";
const mocks = vi.hoisted(() => ({ executeQuery: vi.fn() }));
vi.mock("@/lib/backend/api", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/lib/backend/api")>()), executeQuery: mocks.executeQuery }));
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
vi.restoreAllMocks();
mocks.executeQuery.mockReset();
});
const link = { name: "REMOTE", owner: "A_USER", username: "REMOTE_USER", host: "127.0.0.1:2881", created: "" };
function deferred<T>() {
let resolve!: (value: T) => void, reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function button(text: string): HTMLButtonElement {
const match = [...document.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === text);
expect(match, text).toBeDefined();
return match!;
}
async function fill(label: string, value: string) {
const item = [...document.querySelectorAll("label")].find((element) => element.textContent?.includes(label));
const input = item?.querySelector("input");
expect(input, label).toBeTruthy();
input!.value = value;
input!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
}
async function createForm() {
button("databaseLinks.create").click();
await nextTick();
for (const [label, value] of [
["name", "REMOTE_NEW"],
["username", "remote_user"],
["host", "127.0.0.1:2881"],
["remoteTenant", "MixedTenant"],
["password", "local-test-secret"],
])
await fill("databaseLinks." + label, value);
}
function submit() {
document.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
}
async function mount(options: { dbType?: DatabaseType; readonly?: boolean; list?: ReturnType<typeof vi.fn>; owner?: string; name?: string } = {}) {
const pinia = createPinia();
setActivePinia(pinia);
const store = useConnectionStore();
vi.spyOn(store, "getConfig").mockImplementation((id) => ({ id, name: id, db_type: options.dbType ?? "oceanbase-oracle", read_only: options.readonly ?? false }) as ConnectionConfig);
const list = vi.spyOn(store, "listOracleDatabaseLinks").mockImplementation(options.list ?? (async () => [link]));
mocks.executeQuery.mockImplementation(async (connectionId: string, _database: string, sql: string) => ({ columns: ["VALUE"], rows: [[sql.includes("SESSION_USER") ? (connectionId === "a" ? "A_USER" : "B_USER") : 1]] }));
const state = reactive({ connectionId: "a", database: "service", open: true });
const changed = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const app = createApp({ render: () => h(OracleDatabaseLinksDialog, { ...state, name: options.name, owner: options.owner, onChanged: changed }) });
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanups.push(() => {
app.unmount();
host.remove();
});
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull());
return { state, changed, list };
}
async function ready() {
await vi.waitFor(() => expect(button("databaseLinks.create").disabled).toBe(false));
}
describe("DBX Oracle DBLink dialog reused for OceanBase", () => {
it.each(["oracle", "oceanbase-oracle"] as const)("preserves supported controls for %s", async (dbType) => {
await mount({ dbType, name: "REMOTE", owner: "A_USER" });
await ready();
expect([...document.querySelectorAll("button")].some((element) => element.textContent?.trim() === "databaseLinks.alter")).toBe(dbType === "oracle");
button("databaseLinks.create").click();
await nextTick();
expect(document.querySelector("select") !== null).toBe(dbType === "oceanbase-oracle");
const publicControl = [...document.querySelectorAll("label")].find((label) => label.textContent?.includes(dbType === "oceanbase-oracle" ? "databaseLinks.oceanbasePublic" : "databaseLinks.public"));
expect(publicControl).toBeDefined();
expect(publicControl?.querySelector("input")?.disabled).toBe(false);
expect(publicControl?.querySelector("input")?.checked).toBe(false);
});
it("creates through the shared execution guard using SESSION_USER instead of selected schema", async () => {
const { changed, list } = await mount();
await ready();
await createForm();
submit();
await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce());
const request = mocks.executeQuery.mock.calls.find((call) => String(call[2]).startsWith("CREATE DATABASE LINK"));
expect(request?.slice(0, 2)).toEqual(["a", "service"]);
expect(request?.[2]).toContain('CONNECT TO "REMOTE_USER"@"MixedTenant"');
expect(request?.[2]).toContain(" OB HOST '127.0.0.1:2881'");
expect(request?.[2]).not.toContain("CREATE PUBLIC");
expect(request?.[3]).toBe("A_USER");
expect(list).toHaveBeenCalledTimes(2);
});
it("creates the explicit PUBLIC form when the OceanBase option is selected", async () => {
const { changed } = await mount();
await ready();
await createForm();
const publicControl = [...document.querySelectorAll("label")].find((label) => label.textContent?.includes("databaseLinks.oceanbasePublic"));
const checkbox = publicControl?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeDefined();
checkbox!.click();
await nextTick();
submit();
await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce());
const request = mocks.executeQuery.mock.calls.find((call) => String(call[2]).startsWith("CREATE PUBLIC DATABASE LINK"));
expect(request?.[2]).toContain('CONNECT TO "REMOTE_USER"@"MixedTenant"');
});
it("does not leak a supplied password in backend errors", async () => {
await mount();
await ready();
await createForm();
mocks.executeQuery.mockRejectedValueOnce(new Error('rejected IDENTIFIED BY "local-test-secret"'));
submit();
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain("[redacted]"));
expect(document.querySelector('[role="alert"]')?.textContent).not.toContain("local-test-secret");
});
it("does not leak a password prefix from a truncated OceanBase SQL error", async () => {
await mount();
await ready();
await createForm();
mocks.executeQuery.mockRejectedValueOnce(new Error('syntax error near IDENTIFIED BY "local-test-se'));
submit();
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain("[redacted]"));
expect(document.querySelector('[role="alert"]')?.textContent).not.toContain("local-test-se");
});
it("does not send a late identity lookup to a newly selected connection", async () => {
const pending = deferred<(typeof link)[]>();
const { state } = await mount({ list: vi.fn((id: string) => (id === "a" ? pending.promise : Promise.resolve([{ ...link, name: "B_LINK", owner: "B_USER" }]))) });
state.connectionId = "b";
await nextTick();
await ready();
pending.resolve([link]);
await nextTick();
await nextTick();
const identityRequests = mocks.executeQuery.mock.calls.filter((call) => String(call[2]).includes("SESSION_USER"));
expect(identityRequests).toHaveLength(1);
expect(identityRequests[0][0]).toBe("b");
expect(document.body.textContent).toContain("B_LINK");
expect(document.body.textContent).not.toContain("A_USER");
});
it("ignores a write failure belonging to the previous connection", async () => {
const { state, changed } = await mount();
await ready();
await createForm();
const pending = deferred<never>();
mocks.executeQuery.mockImplementationOnce(() => pending.promise);
submit();
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
state.connectionId = "b";
await nextTick();
await ready();
pending.reject(new Error("old-target failure"));
await nextTick();
await nextTick();
expect(changed).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("old-target failure");
expect(button("databaseLinks.create").disabled).toBe(false);
});
it("revalidates the target after awaiting read-only unlock approval", async () => {
const { state, changed } = await mount({ readonly: true });
await ready();
await createForm();
const pending = deferred<boolean>();
const unlock = vi.spyOn(useReadOnlyUnlockStore(), "requestUnlock").mockImplementation(() => pending.promise);
submit();
await vi.waitFor(() => expect(unlock).toHaveBeenCalledOnce());
state.connectionId = "b";
await nextTick();
await ready();
pending.resolve(true);
await nextTick();
await nextTick();
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).startsWith("CREATE"))).toBe(false);
expect(changed).not.toHaveBeenCalled();
});
it("does not test a public link shadowed by the login user's private link", async () => {
await mount({ dbType: "oracle", owner: "PUBLIC", name: "REMOTE", list: vi.fn(async () => [link, { ...link, owner: "PUBLIC" }]) });
await ready();
expect(button("databaseLinks.test").disabled).toBe(true);
expect(document.body.textContent).toContain("databaseLinks.shadowed");
});
it("allows testing but not deleting an OceanBase link created by another user", async () => {
await mount({ owner: "PUBLIC", name: "REMOTE", list: vi.fn(async () => [{ ...link, owner: "PUBLIC", canDrop: false }]) });
await ready();
expect(button("databaseLinks.test").disabled).toBe(false);
expect(button("common.delete").disabled).toBe(true);
expect(document.body.textContent).toContain("databaseLinks.oceanbaseSharedReadOnly");
});
});
@@ -2595,6 +2595,7 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false
title: `Source - ${node.label}`,
schema,
catalog: node.catalog,
initialEditing,
request: { name: sourceTarget.name, objectType: sourceTarget.objectType, signature: sourceNode.signature },
});
return;
@@ -6380,8 +6381,11 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "sequence") {
if (node.type === "sequence" || (node.type === "synonym" && currentDatabaseType() === "oceanbase-oracle")) {
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
if (currentDatabaseType() === "oceanbase-oracle") {
items.push({ label: t("contextMenu.editObject"), action: () => openObjectSourceDialog(true), icon: Pencil });
}
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
return true;
+6
View File
@@ -3885,6 +3885,12 @@ export default {
loadFailed: "Failed to load schemas: {message}",
},
databaseLinks: {
remoteTenant: "Remote tenant",
remoteProtocol: "Remote database protocol",
remoteCluster: "Remote cluster (optional)",
oceanbaseRecreateHint: "In-place updates are not available for OceanBase links. Create and test a replacement before explicitly deleting the old link. Existing links are never dropped automatically.",
oceanbasePublic: "Public",
oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.",
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",
+6
View File
@@ -3756,6 +3756,12 @@ export default withEnglishFallback({
loadFailed: "No se pudieron cargar los schemas: {message}",
},
databaseLinks: {
remoteTenant: "Inquilino remoto",
remoteProtocol: "Protocolo de la base de datos remota",
remoteCluster: "Clúster remoto (opcional)",
oceanbaseRecreateHint: "Los enlaces de OceanBase no se modifican directamente. Cree y pruebe un reemplazo antes de eliminar expresamente el enlace anterior. Nunca se eliminan enlaces automáticamente.",
oceanbasePublic: "Public",
oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.",
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
+6
View File
@@ -3754,6 +3754,12 @@ export default withEnglishFallback({
loadFailed: "Impossibile caricare gli schema: {message}",
},
databaseLinks: {
remoteTenant: "Tenant remoto",
remoteProtocol: "Protocollo del database remoto",
remoteCluster: "Cluster remoto (facoltativo)",
oceanbaseRecreateHint: "I collegamenti OceanBase non si modificano direttamente. Creare e verificare un sostituto prima di eliminare esplicitamente il collegamento precedente. Nessun collegamento viene eliminato automaticamente.",
oceanbasePublic: "Public",
oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.",
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
+6
View File
@@ -3780,6 +3780,12 @@ export default withEnglishFallback({
loadFailed: "スキーマの読み込みに失敗しました: {message}",
},
databaseLinks: {
remoteTenant: "リモートテナント",
remoteProtocol: "リモートデータベースのプロトコル",
remoteCluster: "リモートクラスタ(任意)",
oceanbaseRecreateHint: "OceanBaseリンクの直接変更には対応していません。代替リンクを作成してテストした後、古いリンクを明示的に削除してください。既存リンクを自動削除することはありません。",
oceanbasePublic: "Public",
oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.",
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
+6
View File
@@ -3642,6 +3642,12 @@ export default withEnglishFallback({
loadFailed: "스키마를 불러오는 데 실패했습니다: {message}",
},
databaseLinks: {
remoteTenant: "원격 테넌트",
remoteProtocol: "원격 데이터베이스 프로토콜",
remoteCluster: "원격 클러스터(선택 사항)",
oceanbaseRecreateHint: "OceanBase 링크는 직접 수정할 수 없습니다. 대체 링크를 만들고 테스트한 후 기존 링크를 명시적으로 삭제하세요. 기존 링크를 자동으로 삭제하지 않습니다.",
oceanbasePublic: "Public",
oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.",
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
+6
View File
@@ -3756,6 +3756,12 @@ export default withEnglishFallback({
loadFailed: "Falha ao carregar schemas: {message}",
},
databaseLinks: {
remoteTenant: "Tenant remoto",
remoteProtocol: "Protocolo do banco de dados remoto",
remoteCluster: "Cluster remoto (opcional)",
oceanbaseRecreateHint: "Links do OceanBase não são alterados diretamente. Crie e teste um substituto antes de excluir explicitamente o link antigo. Links existentes nunca são excluídos automaticamente.",
oceanbasePublic: "Public",
oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.",
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
+6
View File
@@ -3796,6 +3796,12 @@ export default withEnglishFallback({
loadFailed: "加载 Schema 失败:{message}",
},
databaseLinks: {
remoteTenant: "远程租户",
remoteProtocol: "远程数据库协议",
remoteCluster: "远程集群(可选)",
oceanbaseRecreateHint: "OceanBase 链接暂不提供原地修改。请先新建并测试替代链接,再明确删除旧链接;不会自动删除已有链接。",
oceanbasePublic: "公有",
oceanbaseSharedReadOnly: "此公有链接由其他用户创建。你可以测试和使用,但只有创建者可以删除。",
shadowed: "当前会话会优先使用同名私有链接。请使用没有该私有链接的用户测试此公有链接。",
search: "搜索数据库链接",
name: "链接名称",
+6
View File
@@ -3755,6 +3755,12 @@ export default withEnglishFallback({
loadFailed: "載入 Schema 失敗:{message}",
},
databaseLinks: {
remoteTenant: "遠端租戶",
remoteProtocol: "遠端資料庫協定",
remoteCluster: "遠端叢集(選填)",
oceanbaseRecreateHint: "OceanBase 連結暫不提供原地修改。請先建立並測試替代連結,再明確刪除舊連結;不會自動刪除既有連結。",
oceanbasePublic: "公有",
oceanbaseSharedReadOnly: "此公有連結由其他使用者建立。你可以測試和使用,但只有建立者可以刪除。",
shadowed: "A private link with the same name takes precedence in this session.",
search: "Search database links",
name: "Link name",
@@ -20,16 +20,15 @@ describe("databaseObjectCapabilities", () => {
it("exposes synonyms only for database paths with synonym metadata", () => {
expect(sidebarObjectKindsForDatabase("oracle")).toContain("SYNONYM");
expect(sidebarObjectKindsForDatabase("xugu")).toContain("SYNONYM");
expect(sidebarObjectKindsForDatabase("oceanbase-oracle")).not.toContain("SYNONYM");
expect(sidebarObjectKindsForDatabase("oceanbase-oracle")).toContain("SYNONYM");
expect(sidebarObjectKindsForDatabase("postgres")).not.toContain("SYNONYM");
});
it("exposes OceanBase Oracle sequences without widening synonym support", () => {
it("exposes OceanBase Oracle sequences and synonyms through the grouped object path", () => {
const oceanBaseObjects = sidebarObjectKindsForDatabase("oceanbase-oracle");
expect(oceanBaseObjects).toEqual(["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]);
expect(databaseObjectCapabilities("oceanbase-oracle").sourceReadable).toEqual(["VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]);
expect(oceanBaseObjects).not.toContain("SYNONYM");
expect(oceanBaseObjects).toEqual(["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"]);
expect(databaseObjectCapabilities("oceanbase-oracle").sourceReadable).toEqual(["VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"]);
expect(
buildObjectGroupPlaceholderNodes({
nodeId: "connection:database:APP",
@@ -38,7 +37,7 @@ describe("databaseObjectCapabilities", () => {
schema: "APP",
objectTypes: oceanBaseObjects,
}).map((node) => node.type),
).toEqual(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages"]);
).toEqual(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-synonyms", "group-packages"]);
});
it("exposes Oracle sequences through the existing grouped object path", () => {
@@ -1,6 +1,57 @@
import { describe, expect, it } from "vitest";
import { ORACLE_DATABASE_LINKS_SQL, createOracleDatabaseLinkSql, alterOracleDatabaseLinkSql, dropOracleDatabaseLinkSql, oracleDatabaseLinkName } from "@/lib/database/oracleDatabaseLinks";
import {
OCEANBASE_ORACLE_DATABASE_LINKS_SQL,
ORACLE_DATABASE_LINKS_SQL,
createOracleDatabaseLinkSql,
createOceanBaseDatabaseLinkSql,
supportsOracleDatabaseLinks,
alterOracleDatabaseLinkSql,
dropOracleDatabaseLinkSql,
oracleDatabaseLinkName,
oracleDatabaseLinksSql,
redactOracleDatabaseLinkError,
testOracleDatabaseLinkSql,
} from "@/lib/database/oracleDatabaseLinks";
import { databaseObjectCapabilities } from "@/lib/database/databaseObjectCapabilities";
import { oracleDatabaseLinkCompletionContext as context, oracleDatabaseLinkCompletionItems as items } from "@/lib/sql/oracleDatabaseLinkCompletion";
describe("OceanBase Oracle schema objects", () => {
it("exposes sequence and synonym source through the same object model as Oracle", () => {
for (const db of ["oracle", "oceanbase-oracle"] as const) {
const capabilities = databaseObjectCapabilities(db);
expect(capabilities.sidebarObjects).toEqual(expect.arrayContaining(["SEQUENCE", "SYNONYM"]));
expect(capabilities.sourceReadable).toEqual(expect.arrayContaining(["SEQUENCE", "SYNONYM"]));
expect(supportsOracleDatabaseLinks(db)).toBe(true);
}
expect(supportsOracleDatabaseLinks("mysql")).toBe(false);
expect(supportsOracleDatabaseLinks(undefined)).toBe(false);
});
const input = { name: "REMOTE.EXAMPLE", public: false, username: "remote_user", tenant: "MixedTenant", password: "test-only@pass", host: "127.0.0.1:2881", protocol: "OB" as const };
it("creates OB links using HOST and preserves tenant and cluster case", () => {
expect(createOceanBaseDatabaseLinkSql({ ...input, cluster: "MixedCluster" })).toBe('CREATE DATABASE LINK REMOTE.EXAMPLE CONNECT TO "REMOTE_USER"@"MixedTenant" IDENTIFIED BY "test-only@pass" OB HOST \'127.0.0.1:2881\' CLUSTER "MixedCluster"');
});
it("generates the explicit PUBLIC form when selected", () => {
expect(createOceanBaseDatabaseLinkSql({ ...input, public: true })).toBe('CREATE PUBLIC DATABASE LINK REMOTE.EXAMPLE CONNECT TO "REMOTE_USER"@"MixedTenant" IDENTIFIED BY "test-only@pass" OB HOST \'127.0.0.1:2881\'');
});
it("creates OCI links using the oracle tenant and preserves quoted remote users", () => {
expect(createOceanBaseDatabaseLinkSql({ ...input, username: '"MixedUser"', protocol: "OCI", host: "127.0.0.1:1521/service" })).toBe('CREATE DATABASE LINK REMOTE.EXAMPLE CONNECT TO "MixedUser"@"oracle" IDENTIFIED BY "test-only@pass" OCI HOST \'127.0.0.1:1521/service\'');
});
it("escapes host literals rather than accepting SQL syntax", () => {
expect(createOceanBaseDatabaseLinkSql({ ...input, host: "bad'host" })).toContain("HOST 'bad''host'");
});
it("redacts complete and truncated passwords echoed by database errors", () => {
expect(redactOracleDatabaseLinkError('failed: IDENTIFIED BY "test-only@pass" OB HOST', "test-only@pass")).not.toContain("test-only@pass");
expect(redactOracleDatabaseLinkError('failed near IDENTIFIED BY "test-on', "test-only@pass")).toBe('failed near IDENTIFIED BY "[redacted]"');
});
it.each([{ tenant: "" }, { tenant: "\n" }, { password: "" }, { password: 'p"ass' }, { password: "p\nass" }, { host: "" }, { host: "bad\0host" }, { name: "x;DROP TABLE T" }, { protocol: "OCI", cluster: "c" }, { username: "" }])("rejects malformed link inputs %j", (overrides) => {
expect(() => createOceanBaseDatabaseLinkSql({ ...input, ...overrides } as Parameters<typeof createOceanBaseDatabaseLinkSql>[0])).toThrow();
});
it("enables the shared @link completion only in supported contexts", () => {
const sql = "SELECT * FROM DUAL@REM";
expect(context(sql, sql.length, "oceanbase-oracle")).toEqual({ prefix: "REM", from: sql.indexOf("REM"), to: sql.length });
for (const text of ["SELECT 'DUAL@", "-- DUAL@", "SELECT @@"]) expect(context(text, text.length, "oceanbase-oracle")).toBeNull();
});
});
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) => {
@@ -27,6 +78,17 @@ describe("Oracle database links", () => {
expect(ORACLE_DATABASE_LINKS_SQL).toContain("'PUBLIC'");
expect(ORACLE_DATABASE_LINKS_SQL).not.toContain("CURRENT_SCHEMA");
});
it("lists tenant-visible OceanBase links and marks links created by the session user", () => {
expect(oracleDatabaseLinksSql("oceanbase-oracle")).toBe(OCEANBASE_ORACLE_DATABASE_LINKS_SQL);
expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("FROM ALL_DB_LINKS");
expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("FROM USER_DB_LINKS");
expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("DBX_CAN_DROP");
expect(oracleDatabaseLinksSql("oracle")).toBe(ORACLE_DATABASE_LINKS_SQL);
});
it("tests OceanBase links through a remote catalog table instead of DUAL", () => {
expect(testOracleDatabaseLinkSql(privateLink, "oceanbase-oracle")).toBe("SELECT 1 AS DBX_LINK_OK FROM SYS.ALL_USERS@REMOTE.EXAMPLE.COM WHERE ROWNUM = 1");
expect(testOracleDatabaseLinkSql(privateLink, "oracle")).toBe("SELECT 1 AS DBX_LINK_OK FROM DUAL@REMOTE.EXAMPLE.COM");
});
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'`);
});
@@ -34,5 +96,6 @@ describe("Oracle database links", () => {
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("drops OceanBase public links with the syntax accepted by OceanBase 4.2.5", () => expect(dropOracleDatabaseLinkSql({ ...privateLink, owner: "PUBLIC" }, "oceanbase-oracle")).toBe("DROP 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());
});
@@ -33,7 +33,7 @@ const VASTBASE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VI
const POSTGRES_LIKE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION"];
const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"];
const OCEANBASE_ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"];
const OCEANBASE_ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"];
const DAMENG_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"];
const XUGU_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"];
const PACKAGE_MEMBER_EXPANSION_DATABASES = new Set<DatabaseType>(["oracle", "xugu"]);
@@ -1,4 +1,8 @@
import type { QueryResult } from "@/types/database";
import type { DatabaseType, QueryResult } from "@/types/database";
export function supportsOracleDatabaseLinks(databaseType?: DatabaseType): boolean {
return databaseType === "oracle" || databaseType === "oceanbase-oracle";
}
export interface OracleDatabaseLink {
name: string;
@@ -6,6 +10,7 @@ export interface OracleDatabaseLink {
username: string;
host: string;
created: string;
canDrop?: boolean;
}
// Links belong to the login user, independently of ALTER SESSION SET CURRENT_SCHEMA.
@@ -16,12 +21,33 @@ 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`;
// OceanBase 4.2.5 links created without a PUBLIC keyword are tenant-visible
// and ALL_DB_LINKS reports them as PUBLIC. USER_DB_LINKS identifies links
// created by the login user, which are the only links that user can drop.
export const OCEANBASE_ORACLE_DATABASE_LINKS_SQL = `SELECT L.OWNER, L.DB_LINK, L.USERNAME, L.HOST,
TO_CHAR(L.CREATED, 'YYYY-MM-DD HH24:MI:SS') AS CREATED,
CASE WHEN EXISTS (SELECT 1 FROM USER_DB_LINKS U WHERE U.DB_LINK = L.DB_LINK) THEN 1 ELSE 0 END AS DBX_CAN_DROP
FROM ALL_DB_LINKS L
ORDER BY L.DB_LINK`;
export function oracleDatabaseLinksSql(databaseType?: DatabaseType): string {
return databaseType === "oceanbase-oracle" ? OCEANBASE_ORACLE_DATABASE_LINKS_SQL : ORACLE_DATABASE_LINKS_SQL;
}
export function oracleDatabaseLinksFromResult(result: QueryResult): OracleDatabaseLink[] {
const indexes = new Map(result.columns.map((name, index) => [name.toUpperCase(), index]));
const canDropIndex = indexes.get("DBX_CAN_DROP");
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") };
return {
name: value("DB_LINK"),
owner: value("OWNER"),
username: value("USERNAME"),
host: value("HOST"),
created: value("CREATED"),
canDrop: canDropIndex === undefined || String(row[canDropIndex]) === "1",
};
})
.filter((link) => !!link.name && !!link.owner);
}
@@ -33,25 +59,53 @@ export function oracleDatabaseLinkName(name: string): string {
return name;
}
export function redactOracleDatabaseLinkError(error: unknown, secret: string): string {
let message = String(error);
if (secret) message = message.replaceAll(secret, "[redacted]");
// OceanBase may truncate the echoed SQL in the middle of a password, so
// replacing only the complete input is insufficient.
return message.replace(/(IDENTIFIED\s+BY\s+")[^"]*(?:"|$)/gi, '$1[redacted]"');
}
function oracleQuotedIdentifier(value: string, fromMetadata = false): string {
if (!value || /[\0\r\n]/.test(value)) throw new Error("Invalid identifier");
if (!value || value.includes("\0") || value.includes("\r") || value.includes("\n")) 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");
if (!input.host.trim() || !input.password || input.password.includes("\0") || input.password.includes("\r") || input.password.includes("\n") || input.password.includes('"')) {
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 createOceanBaseDatabaseLinkSql(input: { name: string; public: boolean; username: string; tenant: string; password: string; host: string; protocol: "OB" | "OCI"; cluster?: string }): string {
if (!["OB", "OCI"].includes(input.protocol)) throw new Error("Unsupported database link protocol");
if (!input.password || input.password.includes("\0") || input.password.includes("\r") || input.password.includes("\n") || input.password.includes('"') || !input.host.trim() || input.host.includes("\0") || input.host.includes("\r") || input.host.includes("\n")) {
throw new Error("A host and a password without double quotes or line breaks are required");
}
const tenant = input.protocol === "OCI" ? "oracle" : input.tenant.trim();
const cluster = input.cluster?.trim();
if (input.protocol === "OCI" && cluster) throw new Error("Cluster is only supported for OceanBase links");
// Tenant/cluster names are case-sensitive configuration, not unquoted SQL identifiers.
// 4.2.5 rejects the Oracle-compatible PUBLIC keyword, while links created
// with this syntax are visible tenant-wide and reported as OWNER=PUBLIC.
return `CREATE ${input.public ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(input.name)} CONNECT TO ${oracleQuotedIdentifier(input.username)}@${oracleQuotedIdentifier(tenant, true)} IDENTIFIED BY "${input.password}" ${input.protocol} HOST '${input.host.replaceAll("'", "''")}'${cluster ? ` CLUSTER ${oracleQuotedIdentifier(cluster, true)}` : ""}`;
}
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");
if (!link.username || !password || password.includes("\0") || password.includes("\r") || password.includes("\n") || password.includes('"')) {
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 dropOracleDatabaseLinkSql(link: OracleDatabaseLink, databaseType?: DatabaseType): string {
const publicKeyword = databaseType !== "oceanbase-oracle" && link.owner === "PUBLIC" ? "PUBLIC " : "";
return `DROP ${publicKeyword}DATABASE LINK ${oracleDatabaseLinkName(link.name)}`;
}
export function testOracleDatabaseLinkSql(link: OracleDatabaseLink): string {
return `SELECT 1 AS DBX_LINK_OK FROM DUAL@${oracleDatabaseLinkName(link.name)}`;
export function testOracleDatabaseLinkSql(link: OracleDatabaseLink, databaseType?: DatabaseType): string {
const name = oracleDatabaseLinkName(link.name);
return databaseType === "oceanbase-oracle" ? `SELECT 1 AS DBX_LINK_OK FROM SYS.ALL_USERS@${name} WHERE ROWNUM = 1` : `SELECT 1 AS DBX_LINK_OK FROM DUAL@${name}`;
}
@@ -1,9 +1,9 @@
import type { DatabaseType } from "@/types/database";
import type { OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks";
import { supportsOracleDatabaseLinks, 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;
if (!supportsOracleDatabaseLinks(databaseType) || 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;
@@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => {
const connectionStore = {
activeConnectionId: null as string | null,
ensureConnected: vi.fn(async () => {}),
getConfig: (connectionId: string) => (connectionId ? { id: connectionId, name: connectionId, db_type: "oracle" } : undefined),
dbType: "oracle",
getConfig: (connectionId: string) => (connectionId ? { id: connectionId, name: connectionId, db_type: connectionStore.dbType } : undefined),
};
return {
connectionStore,
@@ -36,7 +37,7 @@ const CONNECTION_ID = "ora-1";
const DATABASE = "ORCL";
const SCHEMA = "APP";
const pendingOptions = (request: { name: string; objectType: "VIEW" | "PROCEDURE" | "FUNCTION"; signature?: string }) => ({
const pendingOptions = (request: { name: string; objectType: "VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE"; signature?: string }) => ({
connectionId: CONNECTION_ID,
database: DATABASE,
title: `Source - ${request.name}`,
@@ -59,6 +60,7 @@ describe("queryStore pending object source tab", () => {
vi.clearAllMocks();
vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() });
setActivePinia(createPinia());
mocks.connectionStore.dbType = "oracle";
});
it("creates a visible loading tab before the source arrives", async () => {
@@ -269,4 +271,32 @@ describe("queryStore pending object source tab", () => {
expect(tab.objectSource).toBeUndefined();
expect(tab.sourceLoad).toBeUndefined();
});
it("keeps OceanBase sequence CREATE source for viewing and uses ALTER only for editing", async () => {
mocks.connectionStore.dbType = "oceanbase-oracle";
mocks.getObjectSource.mockResolvedValue(objectSource('CREATE SEQUENCE "APP"."SEQ_USERS" START WITH 10 INCREMENT BY 2'));
mocks.buildEditableObjectSource.mockResolvedValue('ALTER SEQUENCE "APP"."SEQ_USERS" INCREMENT BY 2');
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const base = pendingOptions({ name: "SEQ_USERS", objectType: "SEQUENCE" });
store.openObjectSourceTabPending({ ...base, initialEditing: false });
await settle();
expect(store.tabs[0]?.sql).toContain("CREATE SEQUENCE");
expect(store.tabs[0]?.sql).toContain("START WITH 10");
expect(store.tabs[0]?.objectSource).toBeUndefined();
store.openObjectSourceTabPending({ ...base, initialEditing: true });
await settle();
expect(store.tabs).toHaveLength(2);
expect(store.tabs[1]?.sql).toBe('ALTER SEQUENCE "APP"."SEQ_USERS" INCREMENT BY 2');
expect(store.tabs[1]?.objectSource).toMatchObject({ schema: SCHEMA, name: "SEQ_USERS", objectType: "SEQUENCE" });
mocks.buildEditableObjectSource.mockResolvedValue('ALTER SEQUENCE "APP"."SEQ_USERS" INCREMENT BY 4');
const editId = store.tabs[1]!.id;
expect(store.openObjectSourceTabPending({ ...base, initialEditing: true })).toBe(editId);
await settle();
expect(store.tabs).toHaveLength(2);
expect(store.tabs[1]?.sql).toBe('ALTER SEQUENCE "APP"."SEQ_USERS" INCREMENT BY 4');
});
});
+5 -4
View File
@@ -105,7 +105,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 { oracleDatabaseLinksFromResult, oracleDatabaseLinksSql, supportsOracleDatabaseLinks } 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";
@@ -1984,15 +1984,16 @@ export const useConnectionStore = defineStore("connection", () => {
function buildOracleDatabaseLinksNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
const config = getConfig(connectionId);
if (effectiveDatabaseTypeForConnection(config) !== "oracle") return undefined;
if (!supportsOracleDatabaseLinks(effectiveDatabaseTypeForConnection(config))) 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 [];
const databaseType = effectiveDatabaseTypeForConnection(getConfig(connectionId));
if (!supportsOracleDatabaseLinks(databaseType)) return [];
await ensureConnected(connectionId);
return oracleDatabaseLinksFromResult(await api.executeQuery(connectionId, database, ORACLE_DATABASE_LINKS_SQL, undefined, undefined, { maxRows: 10000, timeoutSecs: 15 }));
return oracleDatabaseLinksFromResult(await api.executeQuery(connectionId, database, oracleDatabaseLinksSql(databaseType), undefined, undefined, { maxRows: 10000, timeoutSecs: 15 }));
}
async function refreshOracleDatabaseLinks(connectionId: string) {
+22 -15
View File
@@ -202,6 +202,7 @@ interface OpenPendingObjectSourceTabOptions {
title: string;
schema?: string;
catalog?: string;
initialEditing?: boolean;
request: ObjectSourceRequestIdentity;
}
@@ -2716,6 +2717,7 @@ export const useQueryStore = defineStore("query", () => {
tab.database === options.database &&
(tab.schema || "") === (options.schema || "") &&
(tab.catalog || "") === (options.catalog || "") &&
(tab.sourceLoad.initialEditing ?? true) === (options.initialEditing ?? true) &&
tab.sourceLoad.request.name === options.request.name &&
tab.sourceLoad.request.objectType === options.request.objectType &&
(tab.sourceLoad.request.signature || "") === (options.request.signature || ""),
@@ -2732,15 +2734,18 @@ export const useQueryStore = defineStore("query", () => {
// 两条弯路都要避开 —— 再建一个 pending tab 会让界面上多出一个转圈 tab,
// 随后又被交接逻辑关掉;而只切过去不校验,会让重开看到的是旧 DDL
// (改动前每次打开都会重新取源,源码 tab 没有其它刷新入口)。
const loaded = findMatchingObjectSourceTab({
connectionId: options.connectionId,
database: options.database,
title: options.title,
schema: options.schema,
catalog: options.catalog,
sql: "",
objectSource: { schema: options.schema, name: options.request.name, objectType: options.request.objectType, signature: options.request.signature },
});
const loaded =
options.initialEditing === false
? undefined
: findMatchingObjectSourceTab({
connectionId: options.connectionId,
database: options.database,
title: options.title,
schema: options.schema,
catalog: options.catalog,
sql: "",
objectSource: { schema: options.schema, name: options.request.name, objectType: options.request.objectType, signature: options.request.signature },
});
if (loaded) {
loaded.sourceView = true;
switchTab(loaded.id);
@@ -2760,7 +2765,7 @@ export const useQueryStore = defineStore("query", () => {
const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, "", options.catalog, { forceNew: true, sourceView: true });
const tab = tabs.value.find((candidate) => candidate.id === id);
if (tab) tab.sourceLoad = { startedAt: Date.now(), request: { ...options.request } };
if (tab) tab.sourceLoad = { startedAt: Date.now(), initialEditing: options.initialEditing, request: { ...options.request } };
void loadObjectSourceIntoTab(id);
return id;
}
@@ -2799,7 +2804,7 @@ export const useQueryStore = defineStore("query", () => {
// 期间 tab 可能被关闭、被复用或已被编辑:身份没变且用户没改过内容时才回填
const current = tabs.value.find((candidate) => candidate.id === id);
if (current?.objectSource !== objectSource || resolvedType !== objectSource.objectType) return;
if (raw.editable === false || OBJECT_SOURCE_READ_ONLY_TYPES.includes(resolvedType)) return;
if (raw.editable === false || (OBJECT_SOURCE_READ_ONLY_TYPES.includes(resolvedType) && !(databaseType === "oceanbase-oracle" && resolvedType === "SEQUENCE"))) return;
if (isTabDirty(current)) return;
updateSql(id, editableSource);
markTabClean(current);
@@ -2828,7 +2833,7 @@ export const useQueryStore = defineStore("query", () => {
const { connectionId } = tab;
const { database } = tab;
const schema = tab.schema || database;
const { request } = tab.sourceLoad;
const { request, initialEditing } = tab.sourceLoad;
try {
const connectionStore = useConnectionStore();
await connectionStore.ensureConnected(connectionId);
@@ -2848,7 +2853,7 @@ export const useQueryStore = defineStore("query", () => {
databaseType,
signature: request.signature,
});
applyLoadedObjectSource(id, { connectionId, database, schema, catalog: tab.catalog, title: tab.title, request, editableSource, raw, resolvedType });
applyLoadedObjectSource(id, { connectionId, database, schema, catalog: tab.catalog, title: tab.title, request, initialEditing, databaseType, editableSource, raw, resolvedType });
} catch (e: any) {
// 就地显示错误 + Retry:用户此刻正看着这个 tab,比 toast 更可发现
const failed = tabs.value.find((candidate) => candidate.id === id);
@@ -2865,6 +2870,8 @@ export const useQueryStore = defineStore("query", () => {
catalog?: string;
title: string;
request: ObjectSourceRequestIdentity;
initialEditing?: boolean;
databaseType: DatabaseType;
editableSource: string;
raw: ObjectSource;
resolvedType: ObjectSourceKind;
@@ -2873,7 +2880,7 @@ export const useQueryStore = defineStore("query", () => {
// 加载期间 tab 被关掉(用户放弃)或连接被断开:静默丢弃,不重建、不写库
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.sourceLoad) return;
const sourceIsEditable = loaded.raw.editable !== false && !OBJECT_SOURCE_READ_ONLY_TYPES.includes(loaded.resolvedType);
const sourceIsEditable = loaded.initialEditing !== false && loaded.raw.editable !== false && (!OBJECT_SOURCE_READ_ONLY_TYPES.includes(loaded.resolvedType) || (loaded.databaseType === "oceanbase-oracle" && loaded.resolvedType === "SEQUENCE"));
if (sourceIsEditable) {
const options: OpenObjectSourceTabOptions = {
connectionId: loaded.connectionId,
@@ -2901,7 +2908,7 @@ export const useQueryStore = defineStore("query", () => {
updateSql(id, loaded.editableSource);
setObjectSource(id, options.objectSource);
} else {
updateSql(id, loaded.editableSource);
updateSql(id, loaded.raw.source);
}
tab.sourceView = true;
markTabClean(tab);
+2
View File
@@ -1788,6 +1788,8 @@ export interface QueryTab {
*/
sourceLoad?: {
startedAt: number;
/** Whether this request should open an editable object definition instead of the original source. */
initialEditing?: boolean;
/** 加载失败时写入;保留 request 以便就地重试 */
error?: string;
/**