feat(mongodb): support editing find query results

This commit is contained in:
zipg authored and GitHub committed 2026-06-28 18:59:27 +08:00
1 parent 585a6e2d40
commit 33422ec921
8 files changed
+152 -23

No files matched your search

+28 -14
View File
@@ -2499,13 +2499,17 @@ const canGoNextPage = computed(() => {
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql));
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const canCalculateTotalRowCount = computed(() => !isResultsContext.value && !!props.connectionId && (!!props.tableMeta || !!props.countSql));
const showQueryEditReadyBadge = computed(() => isResultsContext.value && hasData.value && !!props.editable && !!props.tableMeta);
const showQueryEditReadyBadge = computed(() => isResultsContext.value && hasData.value && !!props.editable && (!!props.tableMeta || !!props.customSaveHandler));
const queryEditReadyTargetLabel = computed(() => props.tableMeta?.tableName ?? props.customSaveHandler?.targetLabel ?? "");
const showKeylessEditWarning = computed(() => !!props.editable && !!props.tableMeta && canUseKeylessRowPredicate(props.databaseType, props.tableMeta.primaryKeys ?? []));
const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value);
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
type DataGridTableMeta = NonNullable<typeof props.tableMeta>;
const hiveTableTransactional = ref<boolean | undefined>(undefined);
const canEditExistingRows = computed(() => !!props.customSaveHandler || canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? []));
const customReadonlyColumns = computed(() => new Set((props.customSaveHandler?.readonlyColumns ?? []).map((column) => column.toLowerCase())));
const canInsertRows = computed(() => !props.customSaveHandler || props.customSaveHandler.canInsert !== false);
const canDeleteRows = computed(() => !props.customSaveHandler || props.customSaveHandler.canDelete !== false);
watch(
() => [props.databaseType, props.connectionId, props.database, props.tableMeta?.schema, props.tableMeta?.tableName],
async () => {
@@ -2909,8 +2913,9 @@ function canEditRowItem(item: RowItem | undefined): boolean {
function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean {
if (!canEditRowItem(item) || !canEditColumn(columnIndex)) return false;
const column = props.result.columns[columnIndex] ?? "";
if (customReadonlyColumns.value.has(column.toLowerCase())) return false;
if (!item?.isNew) {
const column = props.result.columns[columnIndex] ?? "";
const sourceColumn = props.sourceColumns?.[columnIndex] ?? column;
if (isClickHouseExistingRowReadonlyColumn(props.databaseType, sourceColumn, props.tableMeta?.primaryKeys ?? [], props.tableMeta?.columns ?? [])) return false;
if (isTdengineExistingRowReadonlyColumn(props.databaseType, column, props.tableMeta?.columns ?? [])) return false;
@@ -3010,7 +3015,7 @@ function isDecimalColumnType(dataType: string): boolean {
}
function canDeleteRowItem(item: RowItem | undefined): boolean {
return !!props.editable && !!item && !item.isDeleted && (item.isNew || canEditExistingRows.value);
return !!props.editable && canDeleteRows.value && !!item && !item.isDeleted && (item.isNew || canEditExistingRows.value);
}
function resetInfiniteScrollState() {
@@ -3050,6 +3055,7 @@ function onToolbarRollback() {
}
function addRow() {
if (!canInsertRows.value) return;
addEditorRow();
focusAppendedTransposeRecord();
}
@@ -4441,7 +4447,7 @@ function onCanvasContext(event: MouseEvent) {
}
const actualColIdx = visibleColumnIndexes.value[hit.visibleColIdx];
if (actualColIdx === undefined) return;
onCellContext(item.id, item.displayIndex, actualColIdx, hit.visibleColIdx);
onCellContext(item.id, item.displayIndex, actualColIdx, hit.visibleColIdx, event);
}
function onCanvasDblClick(event: MouseEvent) {
@@ -5355,7 +5361,7 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "n") {
if (props.editable && (props.tableMeta || props.customSaveHandler)) {
if (props.editable && (props.tableMeta || props.customSaveHandler) && canInsertRows.value) {
event.preventDefault();
event.stopPropagation();
addRow();
@@ -5981,7 +5987,13 @@ async function copyAlterColumnSql() {
toast(t("grid.copyAlterSqlFailed", { message: e?.message || String(e) }), 5000);
}
}
function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleColIdx: number) {
function clearNativeTextSelection() {
window.getSelection()?.removeAllRanges();
}
function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleColIdx: number, event?: MouseEvent) {
event?.preventDefault();
clearNativeTextSelection();
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextCell.value = { rowId, rowIndex, col: colIdx };
@@ -6806,11 +6818,13 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
if (props.editable && contextRowItem.value) {
const labels = rowActionLabels();
items.push({ label: "", separator: true });
items.push({
label: labels.clone,
action: () => (isMultiRow.value ? cloneRows(affectedRowIds()) : cloneRow(contextRowItem.value!.id)),
icon: CopyPlus,
});
if (canInsertRows.value) {
items.push({
label: labels.clone,
action: () => (isMultiRow.value ? cloneRows(affectedRowIds()) : cloneRow(contextRowItem.value!.id)),
icon: CopyPlus,
});
}
if (contextRowItem.value.isDeleted) {
items.push({
label: labels.restore,
@@ -7163,7 +7177,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">
{{ t("grid.queryEditReadyHint", { table: tableMeta?.tableName }) }}
{{ t("grid.queryEditReadyHint", { table: queryEditReadyTargetLabel }) }}
</TooltipContent>
</Tooltip>
<Tooltip v-if="showKeylessEditWarning">
@@ -7216,7 +7230,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.goToColumn") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="editable && (tableMeta || customSaveHandler)">
<Tooltip v-if="editable && (tableMeta || customSaveHandler) && canInsertRows">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '']" @click="addRow">
<Plus class="data-grid-topbar-action-icon w-3 h-3" />
@@ -7999,7 +8013,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@mouseleave="onCellMouseleave(item.displayIndex, col.actualColIdx)"
@dblclick="canEditCellItem(item, col.actualColIdx) && startDomCellEdit(item.id, col.actualColIdx, formatCellCached(item.data[col.actualColIdx], col.actualColIdx), $event)"
:data-visible-col-index="col.visibleColIdx"
@contextmenu="onCellContext(item.id, item.displayIndex, col.actualColIdx, col.visibleColIdx)"
@contextmenu="onCellContext(item.id, item.displayIndex, col.actualColIdx, col.visibleColIdx, $event)"
>
<template v-if="editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx">
<TemporalCellEditor v-if="temporalEditorKindForColumn(col.actualColIdx)" v-model="editValue" :kind="temporalEditorKindForColumn(col.actualColIdx)!" @cancel="cancelEdit" @commit="commitGridEdit" />
@@ -60,9 +60,12 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { formatShortcut } from "@/lib/shortcutRegistry";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { chartableColumnIndexes } from "@/lib/chartData";
import * as api from "@/lib/api";
import { buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongoDocumentValues";
import type { SqlExecutionOverride } from "@/lib/sqlExecutionTarget";
import type { DataGridSortMode } from "@/lib/dataGridSort";
import { useTabScroll } from "@/composables/useTabScroll";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sqlFormatter";
@@ -279,6 +282,60 @@ const hasTabularResult = computed(() => {
return visibleResultItems.value.length > 0;
});
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
type MongoQueryGridChanges = {
dirtyRows: Map<number, Map<number, MongoInputValue>>;
deletedRows: Set<number>;
newRows: MongoInputValue[][];
columns: string[];
rows: MongoInputValue[][];
};
function mongoIdPreview(val: unknown): string {
if (val === null || val === undefined) return "null";
if (typeof val === "string" && /^[a-fA-F0-9]{24}$/.test(val)) return `ObjectId("${val}")`;
return formatMongoShellLiteral(val);
}
function mongoCollectionExpression(collection: string): string {
return `db.getCollection(${JSON.stringify(collection)})`;
}
const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() => {
const tab = props.activeTab;
const target = tab.mongoEditTarget;
if (tab.mode !== "query" || activeEffectiveDatabaseType.value !== "mongodb" || !target || !tab.connectionId || !tab.database || !tab.result) return undefined;
if (!tab.result.columns.includes(target.idColumn)) return undefined;
const save: CustomSaveHandler["save"] = async (changes: MongoQueryGridChanges) => {
if (changes.newRows.length > 0 || changes.deletedRows.size > 0) {
throw new Error("MongoDB query result editing only supports updating existing rows.");
}
const idColIdx = changes.columns.indexOf(target.idColumn);
if (idColIdx < 0) throw new Error("No _id column");
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
const row = changes.rows[rowIdx];
const id = row?.[idColIdx];
if (id === null || id === undefined || String(id).trim() === "") continue;
const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns);
if (Object.keys(updateDoc).length === 0) continue;
await api.mongoUpdateDocument(tab.connectionId, tab.database, target.collection, String(id), JSON.stringify(updateDoc));
}
};
const preview: CustomSaveHandler["preview"] = async (changes: MongoQueryGridChanges) => {
const idColIdx = changes.columns.indexOf(target.idColumn);
if (idColIdx < 0) return [];
const stmts: string[] = [];
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
const row = changes.rows[rowIdx];
const id = row?.[idColIdx];
if (id === null || id === undefined || String(id).trim() === "") continue;
const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns);
if (Object.keys(updateDoc).length === 0) continue;
stmts.push(`${mongoCollectionExpression(target.collection)}.updateOne({_id: ${mongoIdPreview(id)}}, ${formatMongoShellLiteral(updateDoc)})`);
}
return stmts;
};
return { save, preview, canInsert: false, canDelete: false, readonlyColumns: [target.idColumn], targetLabel: target.collection };
});
const resultsPaneOpen = ref(false);
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);
const editorPaneSize = computed(() => (resultsPaneOpen.value ? 100 - resultsPaneSize.value : 100));
@@ -821,8 +878,9 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:initial-order-by-input="activeTab.orderByInput"
:sql="activeTab.lastExecutedSql || activeTab.sql"
:loading="activeTab.isExecuting"
:editable="!!activeTab.queryAnalysis"
:editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler"
:source-columns="activeTab.querySourceColumns"
:custom-save-handler="mongoQueryResultSaveHandler"
context="results"
:database-type="activeEffectiveDatabaseType"
:connection-id="activeTab.connectionId"