feat(mongo): open object browser from database tree nodes
This commit is contained in:
29 files changed
+354
-61
No files matched your search
@@ -2248,7 +2248,7 @@ defineExpose({
|
||||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" />
|
||||
<DocumentBrowser ref="documentBrowserRef" :key="`${activeTab.id}:${activeTab.sql}`" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -9,3 +9,9 @@ describe("ContentArea completed query output", () => {
|
||||
expect(contentAreaSource).toContain('emit("update:activeOutputView", result ? defaultViewForResult(result) : "summary")');
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentArea Mongo tab reuse", () => {
|
||||
it("remounts the document browser when the active tab changes collections", () => {
|
||||
expect(contentAreaSource).toContain(':key="`${activeTab.id}:${activeTab.sql}`"');
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
import { filterSchemaNamesForConnection } from "@/lib/database/visibleDatabases";
|
||||
import {
|
||||
buildObjectBrowserRows,
|
||||
buildMongoObjectBrowserRows,
|
||||
countObjectBrowserRowsByFilter,
|
||||
formatObjectBrowserBytes,
|
||||
formatObjectBrowserCount,
|
||||
@@ -139,6 +140,7 @@ import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleC
|
||||
import { objectBrowserTableSelectionAnchor, objectBrowserTableSelectionRange } from "@/lib/table/objectBrowserSelection";
|
||||
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
|
||||
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
|
||||
import { visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
|
||||
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
||||
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
||||
@@ -237,6 +239,9 @@ const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getT
|
||||
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
|
||||
const isGaussdbM = computed(() => effectiveDatabaseType.value === "gaussdb" && props.connection.driver_profile?.toLowerCase() === "gaussdb-m");
|
||||
const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics");
|
||||
const isMongodb = computed(() => props.connection.db_type === "mongodb");
|
||||
const showObjectRowStats = computed(() => !isMongodb.value);
|
||||
const showObjectSizeStats = computed(() => !isVictoriaMetrics.value && !isMongodb.value);
|
||||
const objectRowsLabel = computed(() => t(isVictoriaMetrics.value ? "objects.series" : "objects.rows"));
|
||||
|
||||
function toggleTableDdlWordWrap() {
|
||||
@@ -502,8 +507,9 @@ function toggleCheckboxColumn() {
|
||||
const objectBrowserColumns = computed<ObjectBrowserColumnKey[]>(() => {
|
||||
const columns: ObjectBrowserColumnKey[] = [];
|
||||
if (showCheckboxColumn.value) columns.push("select");
|
||||
columns.push("name", "type", "estimatedRows");
|
||||
if (!isVictoriaMetrics.value) columns.push("totalBytes");
|
||||
columns.push("name", "type");
|
||||
if (showObjectRowStats.value) columns.push("estimatedRows");
|
||||
if (showObjectSizeStats.value) columns.push("totalBytes");
|
||||
if (hasCreatedAt.value) columns.push("created_at");
|
||||
if (hasUpdatedAt.value) columns.push("updated_at");
|
||||
columns.push("comment");
|
||||
@@ -637,18 +643,23 @@ function iconFor(row: ObjectBrowserRow) {
|
||||
return Table2;
|
||||
}
|
||||
|
||||
function typeLabel(type: ObjectBrowserRow["type"]) {
|
||||
if (type === "MATERIALIZED_VIEW") return t("common.materializedView");
|
||||
if (type === "VIEW") return t("objects.view");
|
||||
if (type === "PROCEDURE") return t("objects.procedure");
|
||||
if (type === "FUNCTION") return t("objects.function");
|
||||
if (type === "TRIGGER") return t("objects.trigger");
|
||||
if (type === "EVENT") return t("tree.events");
|
||||
if (type === "SEQUENCE") return t("objects.sequence");
|
||||
if (type === "PACKAGE") return t("objects.package");
|
||||
if (type === "PACKAGE_BODY") return t("objects.packageBody");
|
||||
if (type === "TYPE") return t("objects.typeDefinition");
|
||||
if (type === "TYPE_BODY") return t("objects.typeBody");
|
||||
function typeLabel(row: ObjectBrowserRow) {
|
||||
if (isMongodb.value) {
|
||||
if (row.collectionKind === "view" || row.type === "VIEW") return t("objects.view");
|
||||
if (row.collectionKind === "timeseries") return t("objects.timeseries");
|
||||
return t("objects.collection");
|
||||
}
|
||||
if (row.type === "MATERIALIZED_VIEW") return t("common.materializedView");
|
||||
if (row.type === "VIEW") return t("objects.view");
|
||||
if (row.type === "PROCEDURE") return t("objects.procedure");
|
||||
if (row.type === "FUNCTION") return t("objects.function");
|
||||
if (row.type === "TRIGGER") return t("objects.trigger");
|
||||
if (row.type === "EVENT") return t("tree.events");
|
||||
if (row.type === "SEQUENCE") return t("objects.sequence");
|
||||
if (row.type === "PACKAGE") return t("objects.package");
|
||||
if (row.type === "PACKAGE_BODY") return t("objects.packageBody");
|
||||
if (row.type === "TYPE") return t("objects.typeDefinition");
|
||||
if (row.type === "TYPE_BODY") return t("objects.typeBody");
|
||||
return t("objects.table");
|
||||
}
|
||||
|
||||
@@ -667,8 +678,9 @@ function toggleSort(key: ObjectBrowserSortKey) {
|
||||
}
|
||||
|
||||
const sortKeyOptions = computed<ObjectBrowserSortKey[]>(() => {
|
||||
const options: ObjectBrowserSortKey[] = ["name", "type", "estimatedRows"];
|
||||
if (!isVictoriaMetrics.value) options.push("totalBytes");
|
||||
const options: ObjectBrowserSortKey[] = ["name", "type"];
|
||||
if (showObjectRowStats.value) options.push("estimatedRows");
|
||||
if (showObjectSizeStats.value) options.push("totalBytes");
|
||||
if (hasCreatedAt.value) options.push("created_at");
|
||||
if (hasUpdatedAt.value) options.push("updated_at");
|
||||
options.push("comment");
|
||||
@@ -893,7 +905,7 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
|
||||
void openTypeInfo(row);
|
||||
break;
|
||||
case "open-table":
|
||||
emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog });
|
||||
emit("openTable", { tableName: row.name, schema: row.schema, tableType: objectBrowserOpenTableType(row), catalog: props.catalog });
|
||||
break;
|
||||
case "open-source":
|
||||
void (row.type === "EVENT" ? openEventEditor(row) : openSource(row));
|
||||
@@ -935,7 +947,7 @@ function onRowClick(row: ObjectBrowserRow, event: MouseEvent) {
|
||||
}
|
||||
// Single click: defer when the row has a distinct double-click action so a
|
||||
// following second click can cancel it (e.g. TABLE single→table-info, double→open-table).
|
||||
if (shouldDeferSingleClick(row, action)) {
|
||||
if (shouldDeferSingleClick(row, action, effectiveDatabaseType.value)) {
|
||||
if (singleClickTimer) clearTimeout(singleClickTimer);
|
||||
singleClickTimer = setTimeout(() => {
|
||||
singleClickTimer = null;
|
||||
@@ -1614,8 +1626,14 @@ async function saveFileContent(content: string, defaultFileName: string, filterN
|
||||
}
|
||||
}
|
||||
|
||||
function objectBrowserOpenTableType(row: ObjectBrowserRow): string {
|
||||
if (row.collectionKind === "view") return "VIEW";
|
||||
if (row.collectionKind === "timeseries") return "TIMESERIES";
|
||||
return row.type;
|
||||
}
|
||||
|
||||
function openViewData(row: ObjectBrowserRow) {
|
||||
emit("openTable", { tableName: row.name, schema: row.schema, tableType: row.type, catalog: props.catalog });
|
||||
emit("openTable", { tableName: row.name, schema: row.schema, tableType: objectBrowserOpenTableType(row), catalog: props.catalog });
|
||||
}
|
||||
|
||||
function openStructureEditor(row: ObjectBrowserRow) {
|
||||
@@ -2178,7 +2196,7 @@ function copySelectedTablesToClipboard() {
|
||||
}
|
||||
|
||||
function canPasteTableClipboard(): boolean {
|
||||
return !isVictoriaMetrics.value && tableClipboardMatchesTarget(normalizedObjectBrowserTableClipboardEntries(), pasteTableTargetContext());
|
||||
return !isVictoriaMetrics.value && !isMongodb.value && tableClipboardMatchesTarget(normalizedObjectBrowserTableClipboardEntries(), pasteTableTargetContext());
|
||||
}
|
||||
|
||||
function normalizedObjectBrowserTableClipboardEntries() {
|
||||
@@ -2687,17 +2705,11 @@ async function loadObjects(options?: { allowCached?: boolean }) {
|
||||
loadingObjects.value = true;
|
||||
rows.value = [];
|
||||
try {
|
||||
const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog);
|
||||
const nextRows = props.connection.db_type === "mongodb" ? await loadMongoObjectBrowserRows(request.scope.connectionId, request.scope.database) : await loadSqlObjectBrowserRows(request);
|
||||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||||
const nextRows = buildObjectBrowserRows({
|
||||
objects,
|
||||
database: request.scope.database,
|
||||
fallbackSchema: request.scope.schema,
|
||||
rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
|
||||
});
|
||||
applyObjectBrowserRows(nextRows);
|
||||
const cachedAt = cacheObjectBrowserRows(cacheWriteToken, nextRows);
|
||||
void loadObjectStatistics(request, cacheWriteToken, cachedAt);
|
||||
if (props.connection.db_type !== "mongodb") void loadObjectStatistics(request, cacheWriteToken, cachedAt);
|
||||
} catch (e: any) {
|
||||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||||
error.value = translateBackendError(t, e);
|
||||
@@ -2706,6 +2718,21 @@ async function loadObjects(options?: { allowCached?: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSqlObjectBrowserRows(request: ObjectBrowserRowsLoadHandle) {
|
||||
const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog);
|
||||
return buildObjectBrowserRows({
|
||||
objects,
|
||||
database: request.scope.database,
|
||||
fallbackSchema: request.scope.schema,
|
||||
rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMongoObjectBrowserRows(connectionId: string, database: string) {
|
||||
const collections = await api.mongoListCollections(connectionId, database);
|
||||
return buildMongoObjectBrowserRows({ collections: visibleMongoCollections(collections), database });
|
||||
}
|
||||
|
||||
async function loadObjectStatistics(request: ObjectBrowserRowsLoadHandle, cacheWriteToken: ObjectBrowserRowsCacheWriteToken, cachedAt: number | undefined) {
|
||||
if (!rows.value.some((row) => row.type === "TABLE")) return;
|
||||
try {
|
||||
@@ -2768,7 +2795,9 @@ function filterCount(filter: ObjectFilter) {
|
||||
function filterLabel(filter: ObjectFilter) {
|
||||
const key =
|
||||
filter === "tables"
|
||||
? "objects.tables"
|
||||
? isMongodb.value
|
||||
? "objects.collections"
|
||||
: "objects.tables"
|
||||
: filter === "views"
|
||||
? "objects.views"
|
||||
: filter === "materializedViews"
|
||||
@@ -3050,6 +3079,13 @@ function getTypeMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
}
|
||||
|
||||
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
if (isMongodb.value) {
|
||||
return [
|
||||
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
|
||||
{ label: "", separator: true },
|
||||
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
|
||||
];
|
||||
}
|
||||
if (item.type === "TABLE") return getTableMenuItems(item);
|
||||
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
|
||||
if (item.type === "EVENT") return getEventMenuItems(item);
|
||||
@@ -3073,7 +3109,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="search" data-object-search-input class="h-7 pl-8 pr-6 text-xs" :placeholder="t('objects.search')" @keydown="onSearchKeydown" />
|
||||
<Input v-model="search" data-object-search-input class="h-7 pl-8 pr-6 text-xs" :placeholder="isMongodb ? t('objects.searchCollections') : t('objects.search')" @keydown="onSearchKeydown" />
|
||||
<button v-if="search" type="button" class="absolute right-1.5 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('common.clear')" @click="clearObjectSearch">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
@@ -3150,23 +3186,23 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
<div class="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{{ t("objects.selectedTables", { count: selectedTableCount }) }}
|
||||
</div>
|
||||
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openBatchDatabaseExport">
|
||||
<Button v-if="showObjectSizeStats" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openBatchDatabaseExport">
|
||||
<Upload class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("objects.exportSelected") }}
|
||||
</Button>
|
||||
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="copySelectedTablesToClipboard">
|
||||
<Button v-if="showObjectSizeStats" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="copySelectedTablesToClipboard">
|
||||
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("objects.copyTableSelected") }}
|
||||
</Button>
|
||||
<Button v-if="!isVictoriaMetrics && supportsTruncateTable" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
|
||||
<Button v-if="showObjectSizeStats && supportsTruncateTable" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
|
||||
<Scissors class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("objects.truncateSelected") }}
|
||||
</Button>
|
||||
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
|
||||
<Button v-if="showObjectSizeStats" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
|
||||
<Eraser class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("contextMenu.batchEmpty", { count: selectedTableCount }) }}
|
||||
</Button>
|
||||
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
|
||||
<Button v-if="showObjectSizeStats" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
|
||||
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("objects.dropSelected") }}
|
||||
</Button>
|
||||
@@ -3217,7 +3253,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
<GripVertical class="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex min-w-0 items-center">
|
||||
<div v-if="showObjectRowStats" class="relative flex min-w-0 items-center">
|
||||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
|
||||
<span class="truncate">{{ objectRowsLabel }}</span>
|
||||
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
|
||||
@@ -3230,7 +3266,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
<GripVertical class="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isVictoriaMetrics" class="relative flex min-w-0 items-center">
|
||||
<div v-if="showObjectSizeStats" class="relative flex min-w-0 items-center">
|
||||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
|
||||
<span class="truncate">{{ t("objects.size") }}</span>
|
||||
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
|
||||
@@ -3318,11 +3354,11 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
{{ t("objects.partitions", { count: item.partitionCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
|
||||
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.estimatedRows == null ? '' : formatObjectBrowserCount(item.estimatedRows)">
|
||||
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item) }}</div>
|
||||
<div v-if="showObjectRowStats" class="truncate text-xs tabular-nums text-muted-foreground" :title="item.estimatedRows == null ? '' : formatObjectBrowserCount(item.estimatedRows)">
|
||||
{{ formatObjectBrowserCount(item.estimatedRows) }}
|
||||
</div>
|
||||
<div v-if="!isVictoriaMetrics" class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserBytes(item.totalBytes)">
|
||||
<div v-if="showObjectSizeStats" class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserBytes(item.totalBytes)">
|
||||
{{ formatObjectBrowserBytes(item.totalBytes) }}
|
||||
</div>
|
||||
<div v-if="hasCreatedAt" class="truncate text-xs tabular-nums text-muted-foreground" :title="formatObjectBrowserTimestamp(item.created_at)">
|
||||
@@ -3363,11 +3399,11 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
</div>
|
||||
<span class="w-full truncate text-sm font-medium leading-tight text-foreground">{{ item.displayName }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-muted-foreground">{{ typeLabel(item.type) }}</span>
|
||||
<span v-if="item.estimatedRows != null && item.estimatedRows > 0" class="object-browser-stat-badge object-browser-stat-badge-rows rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-primary">{{
|
||||
<span class="text-xs text-muted-foreground">{{ typeLabel(item) }}</span>
|
||||
<span v-if="showObjectRowStats && item.estimatedRows != null && item.estimatedRows > 0" class="object-browser-stat-badge object-browser-stat-badge-rows rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-primary">{{
|
||||
formatObjectBrowserCount(item.estimatedRows)
|
||||
}}</span>
|
||||
<span v-if="!isVictoriaMetrics && item.totalBytes != null && item.totalBytes > 0" class="object-browser-stat-badge object-browser-stat-badge-bytes rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">{{
|
||||
<span v-if="showObjectSizeStats && item.totalBytes != null && item.totalBytes > 0" class="object-browser-stat-badge object-browser-stat-badge-bytes rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">{{
|
||||
formatObjectBrowserBytes(item.totalBytes)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@@ -5244,6 +5244,9 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
|
||||
if (node.type === "redis-db" || node.type === "mongo-db" || node.type === "vector-database") {
|
||||
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
|
||||
if (node.type === "mongo-db" && canOpenObjectBrowser.value) {
|
||||
items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
|
||||
}
|
||||
if (!isNodeDefaultDatabase.value) {
|
||||
items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database });
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode";
|
||||
import { buildMongoObjectBrowserRows } from "@/lib/table/objectBrowserRows";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -233,6 +234,65 @@ describe("useNavigationTargets with the real query store", () => {
|
||||
expect(queryStore.activeTabId).toBe(firstTabId);
|
||||
});
|
||||
|
||||
it("reuses only the same MongoDB collection in same-table mode", async () => {
|
||||
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" }));
|
||||
const { navigation, queryStore } = await setupNavigation();
|
||||
const rows = buildMongoObjectBrowserRows({ collections: [{ name: " users " }, { name: "users" }], database: "app" });
|
||||
|
||||
for (const row of rows) {
|
||||
await navigation.openObjectBrowserTableTarget({ connectionId: "connection-1", database: "app", tableName: row.name, tableType: row.type });
|
||||
}
|
||||
await navigation.openObjectBrowserTableTarget({ connectionId: "connection-1", database: "app", tableName: rows[0]!.name, tableType: rows[0]!.type });
|
||||
|
||||
expect(queryStore.tabs).toHaveLength(2);
|
||||
expect(new Set(queryStore.tabs.map((tab) => tab.id))).toHaveLength(2);
|
||||
expect(queryStore.tabs.map((tab) => tab.sql)).toEqual([" users ", "users"]);
|
||||
expect(queryStore.tabs.map((tab) => tab.tableMeta?.tableName)).toEqual([" users ", "users"]);
|
||||
expect(queryStore.activeTabId).toBe(queryStore.tabs[0]?.id);
|
||||
});
|
||||
|
||||
it("always creates a new MongoDB collection tab in always-new mode", async () => {
|
||||
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" }));
|
||||
mocks.settingsStore.editorSettings.dataTabReuseMode = "always-new";
|
||||
const { navigation, queryStore } = await setupNavigation();
|
||||
const target = { connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" };
|
||||
|
||||
await navigation.openObjectBrowserTableTarget(target);
|
||||
await navigation.openObjectBrowserTableTarget(target);
|
||||
|
||||
expect(queryStore.tabs).toHaveLength(2);
|
||||
expect(new Set(queryStore.tabs.map((tab) => tab.id))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reuses the active safe MongoDB collection tab in active-tab mode", async () => {
|
||||
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" }));
|
||||
mocks.settingsStore.editorSettings.dataTabReuseMode = "active-tab";
|
||||
const { navigation, queryStore } = await setupNavigation();
|
||||
const target = { connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" };
|
||||
|
||||
await navigation.openObjectBrowserTableTarget(target);
|
||||
const originalTabId = queryStore.activeTabId;
|
||||
await navigation.openObjectBrowserTableTarget({ ...target, tableName: "orders" });
|
||||
|
||||
expect(queryStore.tabs).toHaveLength(1);
|
||||
expect(queryStore.activeTabId).toBe(originalTabId);
|
||||
expect(queryStore.tabs[0]).toMatchObject({ title: "app.orders", sql: "orders", tableMeta: { tableName: "orders" } });
|
||||
});
|
||||
|
||||
it("does not reuse an unsafe active MongoDB collection tab", async () => {
|
||||
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" }));
|
||||
mocks.settingsStore.editorSettings.dataTabReuseMode = "active-tab";
|
||||
const { navigation, queryStore } = await setupNavigation();
|
||||
const target = { connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" };
|
||||
|
||||
await navigation.openObjectBrowserTableTarget(target);
|
||||
queryStore.tabs[0]!.pinned = true;
|
||||
await navigation.openObjectBrowserTableTarget({ ...target, tableName: "orders" });
|
||||
|
||||
expect(queryStore.tabs).toHaveLength(2);
|
||||
expect(queryStore.tabs.map((tab) => tab.sql)).toEqual(["users", "orders"]);
|
||||
});
|
||||
|
||||
it("reuses a restored legacy MySQL tab when the same table is opened from the sidebar", async () => {
|
||||
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mysql" }));
|
||||
mocks.loadOpenTabsState.mockResolvedValue({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { connectionObjectTreeNodeSchema, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { invalidateTableMetadataCache, loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
|
||||
import { canApplyDataTabMetadata } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { canApplyDataTabMetadata, canReuseActiveMongoTab, type DataTabReuseMode } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { isNoSnapshotErrorResult, isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||||
import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
@@ -26,6 +26,30 @@ export type NavigationTarget = {
|
||||
whereInput?: string;
|
||||
};
|
||||
|
||||
function openMongoCollectionTarget(target: NavigationTarget, reuseMode: DataTabReuseMode) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
connectionStore.activeConnectionId = target.connectionId;
|
||||
const tabTitle = `${target.database}.${target.tableName}`;
|
||||
const sameCollectionTab = reuseMode === "always-new" ? undefined : queryStore.tabs.find((tab) => tab.mode === "mongo" && tab.connectionId === target.connectionId && tab.database === target.database && tab.sql === target.tableName);
|
||||
const activeTab = queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId);
|
||||
const reusableActiveTab = reuseMode === "active-tab" && !sameCollectionTab && canReuseActiveMongoTab(activeTab, target) ? activeTab : undefined;
|
||||
const reusableTab = sameCollectionTab ?? reusableActiveTab;
|
||||
const tabId = reusableTab?.id ?? queryStore.createTab(target.connectionId, target.database, tabTitle, "mongo", undefined, undefined, undefined, { forceNew: true });
|
||||
if (reusableTab) {
|
||||
queryStore.switchTab(tabId);
|
||||
reusableTab.title = tabTitle;
|
||||
}
|
||||
queryStore.updateSql(tabId, target.tableName);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
database: target.database,
|
||||
tableName: target.tableName,
|
||||
tableType: target.tableType || "TABLE",
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function openTableTarget(target: NavigationTarget, options: { tableInfoTab?: TableInfoTab } = {}) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
@@ -34,6 +58,10 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
|
||||
connectionStore.activeConnectionId = target.connectionId;
|
||||
const config = connectionStore.getConfig(target.connectionId);
|
||||
if (config?.db_type === "mongodb") {
|
||||
openMongoCollectionTarget(target, settingsStore.editorSettings.dataTabReuseMode);
|
||||
return;
|
||||
}
|
||||
const tableSchema = connectionObjectTreeNodeSchema(config, target.database, target.schema);
|
||||
const tabTitle = target.catalog ? `${target.catalog}.${tableSchema || target.database}.${target.tableName}` : tableSchema ? `${tableSchema}.${target.tableName}` : target.tableName;
|
||||
if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
|
||||
@@ -287,6 +315,10 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
|
||||
const { openData } = useSidebarDataOpenRuntime();
|
||||
|
||||
async function openObjectBrowserTableTarget(target: NavigationTarget) {
|
||||
if (connectionStore.getConfig(target.connectionId)?.db_type === "mongodb") {
|
||||
openMongoCollectionTarget(target, settingsStore.editorSettings.dataTabReuseMode);
|
||||
return;
|
||||
}
|
||||
if (settingsStore.editorSettings.dataTabReuseMode === "always-new") {
|
||||
await openTableTarget(target);
|
||||
return;
|
||||
|
||||
@@ -3620,14 +3620,17 @@ export default {
|
||||
objects: {
|
||||
all: "All",
|
||||
tables: "Tables",
|
||||
collections: "Collections",
|
||||
views: "Views",
|
||||
procedures: "Procedures",
|
||||
functions: "Functions",
|
||||
sequences: "Sequences",
|
||||
packages: "Packages",
|
||||
table: "Table",
|
||||
collection: "Collection",
|
||||
partitions: "{count} partitions",
|
||||
view: "View",
|
||||
timeseries: "Time Series",
|
||||
procedure: "Procedure",
|
||||
function: "Function",
|
||||
trigger: "Trigger",
|
||||
@@ -3668,6 +3671,7 @@ export default {
|
||||
loadingSchemas: "Loading schemas...",
|
||||
schema: "Schema",
|
||||
search: "Search tables, views, functions, sequences, or procedures...",
|
||||
searchCollections: "Search collections...",
|
||||
loading: "Loading objects...",
|
||||
empty: "No objects found",
|
||||
selectedTables: "{count} tables selected",
|
||||
|
||||
@@ -3440,14 +3440,17 @@ export default withEnglishFallback({
|
||||
objects: {
|
||||
all: "Todos",
|
||||
tables: "Tablas",
|
||||
collections: "Colecciones",
|
||||
views: "Vistas",
|
||||
procedures: "Procedimientos",
|
||||
functions: "Funciones",
|
||||
sequences: "Secuencias",
|
||||
packages: "Paquetes",
|
||||
table: "Tabla",
|
||||
collection: "Colección",
|
||||
partitions: "{count} particiones",
|
||||
view: "Vista",
|
||||
timeseries: "Serie temporal",
|
||||
procedure: "Procedimiento",
|
||||
function: "Función",
|
||||
sequence: "Secuencia",
|
||||
@@ -3473,6 +3476,7 @@ export default withEnglishFallback({
|
||||
loadingSchemas: "Cargando esquemas...",
|
||||
schema: "Esquema",
|
||||
search: "Buscar tablas, vistas, funciones, secuencias o procedimientos...",
|
||||
searchCollections: "Buscar colecciones...",
|
||||
loading: "Cargando objetos...",
|
||||
empty: "No se encontraron objetos",
|
||||
selectedTables: "{count} tablas seleccionadas",
|
||||
|
||||
@@ -3438,14 +3438,17 @@ export default withEnglishFallback({
|
||||
objects: {
|
||||
all: "Tutti",
|
||||
tables: "Tabelle",
|
||||
collections: "Collezioni",
|
||||
views: "Viste",
|
||||
procedures: "Procedure",
|
||||
functions: "Funzioni",
|
||||
sequences: "Sequenze",
|
||||
packages: "Pacchetti",
|
||||
table: "Tabella",
|
||||
collection: "Collezione",
|
||||
partitions: "{count} partizioni",
|
||||
view: "Vista",
|
||||
timeseries: "Serie temporale",
|
||||
procedure: "Procedura",
|
||||
function: "Funzione",
|
||||
sequence: "Sequenza",
|
||||
@@ -3471,6 +3474,7 @@ export default withEnglishFallback({
|
||||
loadingSchemas: "Caricamento schemi...",
|
||||
schema: "Schema",
|
||||
search: "Cerca tabelle, viste, funzioni, sequenze o procedure...",
|
||||
searchCollections: "Cerca collezioni...",
|
||||
loading: "Caricamento oggetti...",
|
||||
empty: "Nessun oggetto trovato",
|
||||
selectedTables: "{count} tabelle selezionate",
|
||||
|
||||
@@ -3500,14 +3500,17 @@ export default withEnglishFallback({
|
||||
objects: {
|
||||
all: "すべて",
|
||||
tables: "テーブル",
|
||||
collections: "コレクション",
|
||||
views: "ビュー",
|
||||
procedures: "プロシージャ",
|
||||
functions: "関数",
|
||||
sequences: "シーケンス",
|
||||
packages: "パッケージ",
|
||||
table: "テーブル",
|
||||
collection: "コレクション",
|
||||
partitions: "{count}パーティション",
|
||||
view: "ビュー",
|
||||
timeseries: "時系列",
|
||||
procedure: "プロシージャ",
|
||||
function: "関数",
|
||||
sequence: "シーケンス",
|
||||
@@ -3533,6 +3536,7 @@ export default withEnglishFallback({
|
||||
loadingSchemas: "スキーマを読み込み中...",
|
||||
schema: "スキーマ",
|
||||
search: "テーブル、ビュー、関数、シーケンス、プロシージャを検索...",
|
||||
searchCollections: "コレクションを検索...",
|
||||
loading: "オブジェクトを読み込み中...",
|
||||
empty: "オブジェクトが見つかりません",
|
||||
selectedTables: "{count}テーブル選択中",
|
||||
|
||||
@@ -3401,14 +3401,17 @@ export default withEnglishFallback({
|
||||
objects: {
|
||||
all: "전체",
|
||||
tables: "테이블",
|
||||
collections: "컬렉션",
|
||||
views: "뷰",
|
||||
procedures: "프로시저",
|
||||
functions: "함수",
|
||||
sequences: "시퀀스",
|
||||
packages: "패키지",
|
||||
table: "테이블",
|
||||
collection: "컬렉션",
|
||||
partitions: "파티션 {count}개",
|
||||
view: "뷰",
|
||||
timeseries: "시계열",
|
||||
procedure: "프로시저",
|
||||
function: "함수",
|
||||
trigger: "트리거",
|
||||
@@ -3438,6 +3441,7 @@ export default withEnglishFallback({
|
||||
loadingSchemas: "스키마를 불러오는 중...",
|
||||
schema: "스키마",
|
||||
search: "테이블, 뷰, 함수, 시퀀스 또는 프로시저 검색...",
|
||||
searchCollections: "컬렉션 검색...",
|
||||
loading: "객체를 불러오는 중...",
|
||||
empty: "객체가 없습니다",
|
||||
selectedTables: "테이블 {count}개 선택됨",
|
||||
|
||||
@@ -3440,14 +3440,17 @@ export default withEnglishFallback({
|
||||
objects: {
|
||||
all: "Todos",
|
||||
tables: "Tabelas",
|
||||
collections: "Coleções",
|
||||
views: "Visões",
|
||||
procedures: "Procedimentos",
|
||||
functions: "Funções",
|
||||
sequences: "Sequências",
|
||||
packages: "Pacotes",
|
||||
table: "Tabela",
|
||||
collection: "Coleção",
|
||||
partitions: "{count} partições",
|
||||
view: "Visão",
|
||||
timeseries: "Série temporal",
|
||||
procedure: "Procedimento",
|
||||
function: "Função",
|
||||
sequence: "Sequência",
|
||||
@@ -3473,6 +3476,7 @@ export default withEnglishFallback({
|
||||
loadingSchemas: "Carregando schemas...",
|
||||
schema: "Schema",
|
||||
search: "Pesquisar tabelas, visões, funções, sequências ou procedimentos...",
|
||||
searchCollections: "Pesquisar coleções...",
|
||||
loading: "Carregando objetos...",
|
||||
empty: "Nenhum objeto encontrado",
|
||||
selectedTables: "{count} tabelas selecionadas",
|
||||
|
||||
@@ -3607,14 +3607,17 @@ export default withEnglishFallback({
|
||||
objects: {
|
||||
all: "全部",
|
||||
tables: "表",
|
||||
collections: "集合",
|
||||
views: "视图",
|
||||
procedures: "存储过程",
|
||||
functions: "函数",
|
||||
sequences: "序列",
|
||||
packages: "包",
|
||||
table: "表",
|
||||
collection: "集合",
|
||||
partitions: "{count} 个分区",
|
||||
view: "视图",
|
||||
timeseries: "时间序列",
|
||||
procedure: "存储过程",
|
||||
function: "函数",
|
||||
trigger: "触发器",
|
||||
@@ -3654,6 +3657,7 @@ export default withEnglishFallback({
|
||||
loadingSchemas: "加载 Schema...",
|
||||
schema: "Schema",
|
||||
search: "搜索表、视图、函数、序列或存储过程...",
|
||||
searchCollections: "搜索集合...",
|
||||
loading: "正在加载对象...",
|
||||
empty: "没有找到对象",
|
||||
selectedTables: "已选择 {count} 张表",
|
||||
|
||||
@@ -3186,14 +3186,17 @@ export default withEnglishFallback({
|
||||
objects: {
|
||||
all: "全部",
|
||||
tables: "資料表",
|
||||
collections: "集合",
|
||||
views: "檢視",
|
||||
procedures: "預存程序",
|
||||
functions: "函式",
|
||||
sequences: "序列",
|
||||
packages: "套件",
|
||||
table: "資料表",
|
||||
collection: "集合",
|
||||
partitions: "{count} 個分割區",
|
||||
view: "檢視",
|
||||
timeseries: "時間序列",
|
||||
procedure: "預存程序",
|
||||
function: "函式",
|
||||
sequence: "序列",
|
||||
@@ -3219,6 +3222,7 @@ export default withEnglishFallback({
|
||||
loadingSchemas: "載入 Schema……",
|
||||
schema: "Schema",
|
||||
search: "搜尋資料表、檢視、函式、序列或預存程序……",
|
||||
searchCollections: "搜尋集合...",
|
||||
loading: "正在載入物件……",
|
||||
empty: "沒有找到物件",
|
||||
selectedTables: "已選擇 {count} 張資料表",
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
supportsConnectionDatabaseBrowser,
|
||||
supportsDatabaseNameCompletion,
|
||||
supportsDatabaseSchemaQualifier,
|
||||
supportsObjectBrowser,
|
||||
supportsObjectBrowserTreeNode,
|
||||
supportsQueryTargetDatabaseListing,
|
||||
supportsQueryEditorBlockComments,
|
||||
supportsSqlInListPaste,
|
||||
@@ -50,6 +52,17 @@ describe("connection database browser", () => {
|
||||
it("follows object browser support without enabling unsupported connection types", () => {
|
||||
expect(supportsConnectionDatabaseBrowser("postgres")).toBe(true);
|
||||
expect(supportsConnectionDatabaseBrowser("redis")).toBe(false);
|
||||
expect(supportsConnectionDatabaseBrowser("mongodb")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("object browser tree nodes", () => {
|
||||
it("opens MongoDB object browser from mongo-db nodes without the SQL database list", () => {
|
||||
expect(supportsObjectBrowser("mongodb")).toBe(true);
|
||||
expect(supportsObjectBrowserTreeNode("mongodb", "mongo-db")).toBe(true);
|
||||
expect(supportsObjectBrowserTreeNode("mongodb", "database")).toBe(false);
|
||||
expect(supportsObjectBrowserTreeNode("mysql", "database")).toBe(true);
|
||||
expect(supportsObjectBrowserTreeNode("mysql", "mongo-db")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -127,6 +127,14 @@ describe("treeNodeClick", () => {
|
||||
expect(shouldOpenObjectBrowserOnSingleClick("table", true)).toBe(false);
|
||||
expect(shouldOpenObjectBrowserOnSingleClick("database", true)).toBe(true);
|
||||
expect(shouldOpenObjectBrowserOnSingleClick("database", false)).toBe(false);
|
||||
expect(shouldOpenObjectBrowserOnSingleClick("mongo-db", true)).toBe(true);
|
||||
expect(shouldOpenObjectBrowserOnSingleClick("mongo-db", false)).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the MongoDB object browser from mongo-db nodes like SQL databases", () => {
|
||||
expect(treeNodeRowAction("mongo-db", true, "single", "mongodb", true, true)).toBe("open-object-browser-and-expand");
|
||||
expect(treeNodeRowAction("mongo-db", true, "single", "mongodb", false, true)).toBe("toggle");
|
||||
expect(treeNodeRowDoubleClickAction("mongo-db", true, "single", true, "mongodb")).toBe("open-object-browser");
|
||||
});
|
||||
|
||||
it("expands package containers while preserving source behavior for leaf packages", () => {
|
||||
|
||||
@@ -18,6 +18,12 @@ describe("ObjectBrowser context watcher", () => {
|
||||
expect(watcher).toContain("reload({ allowCachedObjects: true, contextEpoch })");
|
||||
});
|
||||
|
||||
it("loads MongoDB collections through the existing objects view", () => {
|
||||
expect(objectBrowserSource).toContain("api.mongoListCollections");
|
||||
expect(objectBrowserSource).toContain("buildMongoObjectBrowserRows");
|
||||
expect(objectBrowserSource).toContain("objects.searchCollections");
|
||||
});
|
||||
|
||||
it("ignores connection object replacement when the context values stay unchanged", async () => {
|
||||
const connection = ref({ id: "connection-1" });
|
||||
const database = ref("database-1");
|
||||
|
||||
@@ -177,6 +177,14 @@ describe("shouldDeferSingleClick", () => {
|
||||
expect(shouldDeferSingleClick(tableRow, "open-table")).toBe(false);
|
||||
});
|
||||
|
||||
it("opens MongoDB collections instead of SQL table-info", () => {
|
||||
expect(singleClickRowAction(tableRow, "mongodb")).toBe("open-table");
|
||||
expect(doubleClickRowAction(tableRow, "mongodb")).toBe("open-table");
|
||||
expect(singleClickRowAction(viewRow, "mongodb")).toBe("open-table");
|
||||
expect(doubleClickRowAction(viewRow, "mongodb")).toBe("open-table");
|
||||
expect(shouldDeferSingleClick(tableRow, "open-table", "mongodb")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles null/undefined row", () => {
|
||||
expect(shouldDeferSingleClick(null, "table-info")).toBe(false);
|
||||
expect(shouldDeferSingleClick(undefined, "open-source")).toBe(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { orderItemsByPinnedTreeNodeOrder, removePinnedTreeNodesFromOrder, treeNodePinIdentity, treeNodePinKey } from "@/lib/app/pinnedItems";
|
||||
import { buildObjectBrowserRows, canonicalizeObjectBrowserPinnedTreeNodeIdentity, objectBrowserRowLegacyPinnedTreeNodeIds, objectBrowserRowMatchesPinnedTreeNode, sortObjectBrowserRows, type ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
|
||||
import { buildMongoObjectBrowserRows, buildObjectBrowserRows, canonicalizeObjectBrowserPinnedTreeNodeIdentity, objectBrowserRowLegacyPinnedTreeNodeIds, objectBrowserRowMatchesPinnedTreeNode, sortObjectBrowserRows, type ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
describe("buildObjectBrowserRows", () => {
|
||||
@@ -119,3 +119,32 @@ describe("Object Browser pinned ordering", () => {
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(routineRow, treeNodePinIdentity({ ...routineNode, id: "conn:app:public:functions:run:integer", label: "run(integer)", signature: "integer" }), context)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMongoObjectBrowserRows", () => {
|
||||
it("maps collection kinds and keeps collection names searchable", () => {
|
||||
const rows = buildMongoObjectBrowserRows({
|
||||
collections: [
|
||||
{ name: "users", kind: "collection" },
|
||||
{ name: "active_users", kind: "view" },
|
||||
{ name: "metrics", kind: "timeseries" },
|
||||
],
|
||||
database: "app",
|
||||
});
|
||||
|
||||
expect(rows.map((row) => ({ name: row.name, type: row.type, collectionKind: row.collectionKind }))).toEqual([
|
||||
{ name: "users", type: "TABLE", collectionKind: "collection" },
|
||||
{ name: "active_users", type: "VIEW", collectionKind: "view" },
|
||||
{ name: "metrics", type: "TABLE", collectionKind: "timeseries" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves distinct MongoDB collection identifiers verbatim", () => {
|
||||
const rows = buildMongoObjectBrowserRows({
|
||||
collections: [{ name: " users " }, { name: "users" }, { name: " " }, { name: "" }],
|
||||
database: "app",
|
||||
});
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual([" users ", "users", " "]);
|
||||
expect(new Set(rows.map((row) => row.id))).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -216,11 +216,13 @@ export function supportsObjectBrowser(dbType?: DatabaseType): boolean {
|
||||
}
|
||||
|
||||
export function supportsConnectionDatabaseBrowser(dbType?: DatabaseType): boolean {
|
||||
return supportsObjectBrowser(dbType);
|
||||
// MongoDB reuses the object browser for collections, not the SQL database list.
|
||||
return supportsObjectBrowser(dbType) && dbType !== "mongodb";
|
||||
}
|
||||
|
||||
export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined, nodeType: TreeNodeType): boolean {
|
||||
if (!supportsObjectBrowser(dbType)) return false;
|
||||
if (dbType === "mongodb") return nodeType === "mongo-db";
|
||||
if (nodeType === "database" && usesDatabaseObjectTreeMode(dbType)) return true;
|
||||
if (nodeType === "database" && isSchemaAware(dbType) && dbType !== "sqlserver") return false;
|
||||
return nodeType === "database" || nodeType === "schema" || nodeType === "object-browser";
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
snapshotMongoIndexSpec,
|
||||
toMongoCollectionKind,
|
||||
toMongoIndexRow,
|
||||
visibleMongoCollections,
|
||||
type MongoCreateIndexForm,
|
||||
type MongoIndexSpecSource,
|
||||
} from "../mongoCollectionMutation";
|
||||
@@ -94,6 +95,20 @@ describe("toMongoCollectionKind", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleMongoCollections", () => {
|
||||
it("hides GridFS buckets and their backing collections", () => {
|
||||
expect(
|
||||
visibleMongoCollections([
|
||||
{ name: "users", kind: "collection" },
|
||||
{ name: "fs", kind: "bucket", bucketName: "fs" },
|
||||
{ name: "fs.files", kind: "collection" },
|
||||
{ name: "fs.chunks", kind: "collection" },
|
||||
{ name: "reports", kind: "view" },
|
||||
]).map((collection) => collection.name),
|
||||
).toEqual(["users", "reports"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProtectedMongoIndex", () => {
|
||||
it("protects the default index by name or primary metadata", () => {
|
||||
expect(isProtectedMongoIndex({ name: "_id_", is_primary: false })).toBe(true);
|
||||
|
||||
@@ -34,8 +34,8 @@ export function dataTabOpenModeFromTreeClick(type: TreeNodeType, event: Omit<Sho
|
||||
return matchesModifierOnlyShortcut(event, shortcut) ? "new-tab" : "default";
|
||||
}
|
||||
|
||||
function isSameDatabase(tab: DataTabLike, target: Pick<DataTabTarget, "connectionId" | "database">): boolean {
|
||||
return tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database;
|
||||
function isSameDatabase(tab: DataTabLike, target: Pick<DataTabTarget, "connectionId" | "database">, mode: "data" | "mongo" = "data"): boolean {
|
||||
return tab.mode === mode && tab.connectionId === target.connectionId && tab.database === target.database;
|
||||
}
|
||||
|
||||
function dataTabCatalog(tab: DataTabLike): string {
|
||||
@@ -47,8 +47,16 @@ function isSameTable(tab: DataTabLike, target: DataTabTarget): boolean {
|
||||
return isSameDatabase(tab, target) && dataTabCatalog(tab) === (target.catalog || "") && tabSchema === (target.schema || "") && (tab.tableMeta?.tableName || tab.title) === target.tableName;
|
||||
}
|
||||
|
||||
function canReuseActiveTableTab(tab: DataTabLike | undefined, target: DataTabTarget, mode: "data" | "mongo"): boolean {
|
||||
return tab !== undefined && isSameDatabase(tab, target, mode) && dataTabCatalog(tab) === (target.catalog || "") && !tab.pinned && !tab.isExecuting && !tab.isCancelling && !tab.isExplaining && !tab.txnSessionId && !tab.pendingDataChangeCount && !tab.hasPendingDataEditorDraft;
|
||||
}
|
||||
|
||||
export function canReuseActiveDataTab(tab: DataTabLike | undefined, target: DataTabTarget): boolean {
|
||||
return tab !== undefined && isSameDatabase(tab, target) && dataTabCatalog(tab) === (target.catalog || "") && !tab.pinned && !tab.isExecuting && !tab.isCancelling && !tab.isExplaining && !tab.txnSessionId && !tab.pendingDataChangeCount && !tab.hasPendingDataEditorDraft;
|
||||
return canReuseActiveTableTab(tab, target, "data");
|
||||
}
|
||||
|
||||
export function canReuseActiveMongoTab(tab: DataTabLike | undefined, target: DataTabTarget): boolean {
|
||||
return canReuseActiveTableTab(tab, target, "mongo");
|
||||
}
|
||||
|
||||
export function canApplyDataTabMetadata(tab: DataTabLike | undefined, target: DataTabTarget, signal?: AbortSignal): boolean {
|
||||
|
||||
@@ -73,6 +73,13 @@ export function toMongoCollectionKind(kind?: string | null): MongoCollectionKind
|
||||
return "collection";
|
||||
}
|
||||
|
||||
/** Drop GridFS buckets and their backing `.files` / `.chunks` collections from object lists. */
|
||||
export function visibleMongoCollections<T extends { name: string; kind?: string | null; bucketName?: string }>(collections: readonly T[]): T[] {
|
||||
const bucketNames = new Set(collections.filter((collection) => collection.kind === "bucket" && collection.bucketName).map((collection) => collection.bucketName as string));
|
||||
const hiddenCollectionNames = new Set([...bucketNames].flatMap((bucketName) => [`${bucketName}.files`, `${bucketName}.chunks`]));
|
||||
return collections.filter((collection) => collection.kind !== "bucket" && !hiddenCollectionNames.has(collection.name));
|
||||
}
|
||||
|
||||
export function mongoRenameCollectionPreview(database: string, oldName: string, newName: string): string {
|
||||
return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(oldName)}).renameCollection(${JSON.stringify(newName)})`;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function isRepeatableNavigationTreeNode(type: TreeNodeType): boolean {
|
||||
export function shouldActivateTreeNodeOnSingleClick(type: TreeNodeType, activation: SidebarActivation = "single"): boolean {
|
||||
return activation !== "double" || isDirectNavigationTreeNode(type);
|
||||
}
|
||||
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser"]);
|
||||
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser", "mongo-db"]);
|
||||
|
||||
/** 开关“单击数据库同时打开数据库项目”命中时,单击数据库类节点直接打开对象浏览器标签页。 */
|
||||
export function shouldOpenObjectBrowserOnSingleClick(type: TreeNodeType, enabled: boolean): boolean {
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ObjectBrowserRowAction = "table-info" | "type-info" | "open-table" |
|
||||
*/
|
||||
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
|
||||
if (!row) return "none";
|
||||
if (dbType === "mongodb") return mongoObjectBrowserRowAction(row);
|
||||
if (row.type === "TABLE") return "table-info";
|
||||
if (row.type === "EVENT") return "none";
|
||||
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
|
||||
@@ -31,6 +32,7 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, d
|
||||
*/
|
||||
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
|
||||
if (!row) return "none";
|
||||
if (dbType === "mongodb") return mongoObjectBrowserRowAction(row);
|
||||
if (row.type === "TABLE") return "open-table";
|
||||
if (row.type === "EVENT") return "open-source";
|
||||
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
|
||||
@@ -65,10 +67,10 @@ export function resolveRowClickAction(row: ObjectBrowserRow | null | undefined,
|
||||
* open-table). For rows whose single and double actions are identical
|
||||
* (e.g. VIEW → open-source both), no deferral is needed.
|
||||
*/
|
||||
export function shouldDeferSingleClick(row: ObjectBrowserRow | null | undefined, action: ObjectBrowserRowAction): boolean {
|
||||
export function shouldDeferSingleClick(row: ObjectBrowserRow | null | undefined, action: ObjectBrowserRowAction, dbType?: DatabaseType): boolean {
|
||||
if (action === "none") return false;
|
||||
const single = singleClickRowAction(row);
|
||||
const double = doubleClickRowAction(row);
|
||||
const single = singleClickRowAction(row, dbType);
|
||||
const double = doubleClickRowAction(row, dbType);
|
||||
return single !== double && action === single;
|
||||
}
|
||||
|
||||
@@ -80,6 +82,10 @@ export function isSourceOnlyObjectBrowserRow(row: ObjectBrowserRow): boolean {
|
||||
return row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
|
||||
}
|
||||
|
||||
function mongoObjectBrowserRowAction(row: ObjectBrowserRow): ObjectBrowserRowAction {
|
||||
return row.type === "TABLE" || row.type === "VIEW" ? "open-table" : "none";
|
||||
}
|
||||
|
||||
function canOpenSource(row: ObjectBrowserRow, dbType?: DatabaseType): boolean {
|
||||
// Verified PG-family TYPE rows open the read-only details panel instead;
|
||||
// Xugu keeps its source editor entry through supportsTypeObjectSource below.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ObjectInfo, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import type { MongoCollectionKind, ObjectInfo, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import { pinnedTreeNodeIdentityMatches, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems";
|
||||
import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, compareDatabaseObjectNames, normalizeDatabaseObjectName } from "@/lib/table/tableTree";
|
||||
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
|
||||
|
||||
@@ -9,6 +10,7 @@ export type ObjectBrowserRow = {
|
||||
displayName: string;
|
||||
schema?: string;
|
||||
type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
|
||||
collectionKind?: MongoCollectionKind;
|
||||
valid?: boolean | null;
|
||||
signature?: string | null;
|
||||
comment?: string | null;
|
||||
@@ -189,6 +191,28 @@ export function buildObjectBrowserRows(options: { objects: ObjectInfo[]; databas
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildMongoObjectBrowserRows(options: { collections: Array<{ name: string; kind?: string | null }>; database: string }): ObjectBrowserRow[] {
|
||||
const seen = new Map<string, number>();
|
||||
return options.collections.flatMap((collection) => {
|
||||
const name = collection.name;
|
||||
if (!name) return [];
|
||||
const collectionKind = toMongoCollectionKind(collection.kind);
|
||||
const type: ObjectBrowserRow["type"] = collectionKind === "view" ? "VIEW" : "TABLE";
|
||||
const baseId = `${options.database}:${name}:${type}:${collectionKind}`;
|
||||
const index = seen.get(baseId) ?? 0;
|
||||
seen.set(baseId, index + 1);
|
||||
return [
|
||||
{
|
||||
id: `${baseId}:${index}`,
|
||||
name,
|
||||
displayName: name,
|
||||
type,
|
||||
collectionKind,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function routineSignatureForDisplay(type: ObjectBrowserRow["type"], signature: string | null | undefined): string | undefined {
|
||||
if (type !== "PROCEDURE" && type !== "FUNCTION") return undefined;
|
||||
if (signature == null) return undefined;
|
||||
|
||||
@@ -134,7 +134,7 @@ import { decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, stripD
|
||||
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
|
||||
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
|
||||
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
|
||||
import { mongoCollectionKindFromNode, toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { mongoCollectionKindFromNode, toMongoCollectionKind, visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
|
||||
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
|
||||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||||
@@ -4896,9 +4896,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const load = beginTreeNodeLoad(node);
|
||||
try {
|
||||
const collections = await api.mongoListCollections(connectionId, database);
|
||||
const bucketNames = new Set(collections.filter((c) => c.kind === "bucket" && c.bucketName).map((c) => c.bucketName as string));
|
||||
const hiddenCollectionNames = new Set([...bucketNames].flatMap((bucketName) => [`${bucketName}.files`, `${bucketName}.chunks`]));
|
||||
const collectionEntries = collections.filter((c) => c.kind !== "bucket").filter((c) => !hiddenCollectionNames.has(c.name));
|
||||
const collectionEntries = visibleMongoCollections(collections);
|
||||
const collectionChildren = [...collectionEntries]
|
||||
.sort((left, right) => compareSidebarNames(left.name, right.name))
|
||||
.map((col) => ({
|
||||
|
||||
Reference in new issue
Block a user