feat(oceanbase-oracle): support sequences, synonyms and db links
This commit is contained in:
1 parent
a19a4f595d
commit
04376ef3fb
31 files changed
+1225
-91
No files matched your search
@@ -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>
|
||||
|
||||
+121
@@ -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");
|
||||
});
|
||||
});
|
||||
Vendored
+204
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user