diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index bf76ba2d4f..865b745578 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -172,7 +172,8 @@ import { elasticsearchProfileBodyForResult, parseElasticsearchProfile } from "@/ import * as api from "@/lib/backend/api"; import type { SqlInsertMode } from "@/lib/export/sqlInsertMode"; import { queryResultExportBaseName } from "@/lib/export/saveTextFile"; -import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; +import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; +import { buildMongoQueryResultOperations, formatMongoQueryResultOperationPreview } from "@/lib/mongo/mongoQueryResultEditing"; import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort"; import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar"; import { useTabScroll } from "@/composables/useTabScroll"; @@ -632,50 +633,30 @@ type MongoQueryGridChanges = { columns: string[]; rows: MongoInputValue[][]; }; -function mongoCollectionExpression(collection: string): string { - return `db.getCollection(${JSON.stringify(collection)})`; -} -function mongoQueryResultDocumentId(rowIdx: number, fallback: unknown): unknown { - const document = props.activeTab.result?.mongo_documents?.[rowIdx]; - if (!document || typeof document !== "object" || Array.isArray(document)) return fallback; - return (document as Record)._id ?? fallback; -} const mongoQueryResultSaveHandler = computed(() => { const tab = props.activeTab; const target = tab.mongoEditTarget; if (tab.mode !== "query" || activeEffectiveDatabaseType.value !== "mongodb" || !target || !activeResultConnectionId.value || !activeResultDatabase.value || !tab.result) return undefined; if (!tab.result.columns.includes(target.idColumn)) return undefined; + const editTarget = { idColumn: target.idColumn, documentAt: (rowIdx: number) => tab.result?.mongo_documents?.[rowIdx] }; + 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."); + if (changes.newRows.length > 0) { + throw new Error("MongoDB query result editing does not support inserting 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, tab.result?.mongo_documents?.[rowIdx]); - if (Object.keys(updateDoc).length === 0) continue; - await api.mongoUpdateDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, serializeMongoDocumentId(mongoQueryResultDocumentId(rowIdx, id)), JSON.stringify(updateDoc)); + if (!changes.columns.includes(target.idColumn)) throw new Error("No _id column"); + for (const operation of buildMongoQueryResultOperations(changes, editTarget)) { + const id = serializeMongoDocumentId(operation.id); + if (operation.kind === "delete") { + await api.mongoDeleteDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, id); + } else { + await api.mongoUpdateDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, id, JSON.stringify(operation.update)); + } } }; - 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, tab.result?.mongo_documents?.[rowIdx]); - if (Object.keys(updateDoc).length === 0) continue; - stmts.push(`${mongoCollectionExpression(target.collection)}.updateOne({_id: ${formatMongoShellLiteral(mongoQueryResultDocumentId(rowIdx, id))}}, ${formatMongoShellLiteral(updateDoc)})`); - } - return stmts; - }; + const preview: CustomSaveHandler["preview"] = async (changes: MongoQueryGridChanges) => buildMongoQueryResultOperations(changes, editTarget).map((operation) => formatMongoQueryResultOperationPreview(target.collection, operation)); const applySavedChanges: NonNullable = ({ dirtyRows, columns }) => { const documents = tab.result?.mongo_documents; @@ -702,7 +683,7 @@ const mongoQueryResultSaveHandler = computed(() = } }; - return { save, preview, applySavedChanges, canInsert: false, canDelete: false, supportsInsert: false, readonlyColumns: [target.idColumn], targetLabel: target.collection }; + return { save, preview, applySavedChanges, canInsert: false, canDelete: true, supportsInsert: 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); diff --git a/apps/desktop/src/lib/mongo/__tests__/mongoQueryResultEditing.spec.ts b/apps/desktop/src/lib/mongo/__tests__/mongoQueryResultEditing.spec.ts new file mode 100644 index 0000000000..2349552296 --- /dev/null +++ b/apps/desktop/src/lib/mongo/__tests__/mongoQueryResultEditing.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { buildMongoQueryResultOperations, formatMongoQueryResultOperationPreview, type MongoQueryResultGridChanges } from "@/lib/mongo/mongoQueryResultEditing"; +import type { MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; + +const COLUMNS = ["_id", "name", "score"]; + +function changes(overrides: Partial = {}): MongoQueryResultGridChanges { + return { + dirtyRows: new Map(), + deletedRows: new Set(), + columns: COLUMNS, + rows: [ + ["507f1f77bcf86cd799439011", "ada", 1], + ["507f1f77bcf86cd799439012", "bob", 2], + ], + ...overrides, + }; +} + +function documents(): unknown[] { + return [ + { _id: { $oid: "507f1f77bcf86cd799439011" }, name: "ada", score: 1 }, + { _id: { $oid: "507f1f77bcf86cd799439012" }, name: "bob", score: 2 }, + ]; +} + +function target(documentList: unknown[] = documents()) { + return { idColumn: "_id", documentAt: (rowIndex: number) => documentList[rowIndex] }; +} + +function dirty(entries: [number, [number, MongoInputValue][]][]): Map> { + return new Map(entries.map(([rowIndex, cells]) => [rowIndex, new Map(cells)])); +} + +describe("MongoDB query result grid mutations", () => { + it("turns rows marked for deletion into deleteOne operations keyed by the document _id", () => { + const operations = buildMongoQueryResultOperations(changes({ deletedRows: new Set([1]) }), target()); + + expect(operations).toEqual([{ kind: "delete", rowIndex: 1, id: { $oid: "507f1f77bcf86cd799439012" } }]); + }); + + it("deletes every selected row and keeps updates ahead of deletions", () => { + const operations = buildMongoQueryResultOperations(changes({ dirtyRows: dirty([[0, [[1, "ada2"]]]]), deletedRows: new Set([1]) }), target()); + + expect(operations.map((operation) => [operation.kind, operation.rowIndex])).toEqual([ + ["update", 0], + ["delete", 1], + ]); + }); + + it("falls back to the grid cell when no raw document backs the row", () => { + const operations = buildMongoQueryResultOperations(changes({ deletedRows: new Set([0, 1]) }), target([])); + + expect(operations).toEqual([ + { kind: "delete", rowIndex: 0, id: "507f1f77bcf86cd799439011" }, + { kind: "delete", rowIndex: 1, id: "507f1f77bcf86cd799439012" }, + ]); + }); + + it.each([ + { label: "an out-of-range row index", deletedRows: new Set([7]) }, + { label: "a blank id cell", deletedRows: new Set([0]), rows: [[" ", "ada", 1]] as MongoInputValue[][] }, + { label: "a null id cell", deletedRows: new Set([0]), rows: [[null, "ada", 1]] as MongoInputValue[][] }, + ])("skips a deletion with $label rather than targeting the wrong document", ({ deletedRows, rows }) => { + expect(buildMongoQueryResultOperations(changes({ deletedRows, ...(rows ? { rows } : {}) }), target([]))).toEqual([]); + }); + + it("produces no operations when the result has no _id column", () => { + const withoutId = changes({ columns: ["name", "score"], deletedRows: new Set([0]), dirtyRows: dirty([[1, [[0, "bob2"]]]]) }); + + expect(buildMongoQueryResultOperations(withoutId, target())).toEqual([]); + }); + + it("previews a deletion as a shell deleteOne against the queried collection", () => { + const [operation] = buildMongoQueryResultOperations(changes({ deletedRows: new Set([0]) }), target()); + + expect(formatMongoQueryResultOperationPreview("users", operation!)).toBe('db.getCollection("users").deleteOne({_id: ObjectId("507f1f77bcf86cd799439011")})'); + }); + + it("keeps previewing updates as shell updateOne calls", () => { + const [operation] = buildMongoQueryResultOperations(changes({ dirtyRows: dirty([[1, [[1, "bob2"]]]]) }), target()); + + expect(formatMongoQueryResultOperationPreview("users", operation!)).toBe('db.getCollection("users").updateOne({_id: ObjectId("507f1f77bcf86cd799439012")}, {"$set":{"name":"bob2"}})'); + }); + + it("drops an update whose dirty cells produce no field changes", () => { + const idOnly = changes({ dirtyRows: dirty([[0, [[0, "507f1f77bcf86cd799439099"]]]]) }); + + expect(buildMongoQueryResultOperations(idOnly, target())).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/mongo/mongoQueryResultEditing.ts b/apps/desktop/src/lib/mongo/mongoQueryResultEditing.ts new file mode 100644 index 0000000000..d7efb85756 --- /dev/null +++ b/apps/desktop/src/lib/mongo/mongoQueryResultEditing.ts @@ -0,0 +1,68 @@ +import { buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; + +export interface MongoQueryResultGridChanges { + dirtyRows: Map>; + deletedRows: Set; + columns: string[]; + rows: MongoInputValue[][]; +} + +export interface MongoQueryResultEditTarget { + idColumn: string; + /** Raw document behind a grid row, used for BSON-faithful `_id` values and field types. */ + documentAt: (rowIndex: number) => unknown; +} + +export type MongoQueryResultOperation = { kind: "update"; rowIndex: number; id: unknown; update: Record } | { kind: "delete"; rowIndex: number; id: unknown }; + +/** + * Resolves the `_id` a mutation should target. The grid cell only carries a + * display value, so prefer the raw document's `_id` to keep ObjectId/NumberLong + * types intact, and skip rows whose id is blank (an unsaved or malformed row). + */ +function mongoQueryResultRowId(row: MongoInputValue[] | undefined, idColumnIndex: number, document: unknown): unknown { + const fallback = row?.[idColumnIndex]; + if (fallback === null || fallback === undefined || String(fallback).trim() === "") return undefined; + if (!document || typeof document !== "object" || Array.isArray(document)) return fallback; + return (document as Record)._id ?? fallback; +} + +/** + * Turns pending grid changes into the MongoDB mutations that apply them. + * Updates come first so a shell preview reads in the same order it executes; + * a row marked for deletion never carries dirty cells, since the grid drops + * its edits when the row is marked. + */ +export function buildMongoQueryResultOperations(changes: MongoQueryResultGridChanges, target: MongoQueryResultEditTarget): MongoQueryResultOperation[] { + const idColumnIndex = changes.columns.indexOf(target.idColumn); + if (idColumnIndex < 0) return []; + + const operations: MongoQueryResultOperation[] = []; + + for (const [rowIndex, dirtyColumns] of changes.dirtyRows) { + const document = target.documentAt(rowIndex); + const id = mongoQueryResultRowId(changes.rows[rowIndex], idColumnIndex, document); + if (id === undefined) continue; + const update = buildMongoUpdateDocument(dirtyColumns, changes.columns, document); + if (Object.keys(update).length === 0) continue; + operations.push({ kind: "update", rowIndex, id, update }); + } + + for (const rowIndex of changes.deletedRows) { + const id = mongoQueryResultRowId(changes.rows[rowIndex], idColumnIndex, target.documentAt(rowIndex)); + if (id === undefined) continue; + operations.push({ kind: "delete", rowIndex, id }); + } + + return operations; +} + +export function mongoCollectionExpression(collection: string): string { + return `db.getCollection(${JSON.stringify(collection)})`; +} + +export function formatMongoQueryResultOperationPreview(collection: string, operation: MongoQueryResultOperation): string { + const filter = `{_id: ${formatMongoShellLiteral(operation.id)}}`; + if (operation.kind === "delete") return `${mongoCollectionExpression(collection)}.deleteOne(${filter})`; + return `${mongoCollectionExpression(collection)}.updateOne(${filter}, ${formatMongoShellLiteral(operation.update)})`; +} diff --git a/docs/content/docs/mongodb.cn.mdx b/docs/content/docs/mongodb.cn.mdx index 5e9b29c33d..30aea4d682 100644 --- a/docs/content/docs/mongodb.cn.mdx +++ b/docs/content/docs/mongodb.cn.mdx @@ -68,6 +68,10 @@ db.orders.aggregate([ 字段结构差异很大的 Collection 更适合使用完整文档视图,避免隐藏字段在表格列中被忽略。 +### 查询结果编辑 + +当查询标签中只有一条不带投影的 `find` 或 `findOne`、且结果包含 `_id` 时,结果表格可以直接编辑:可以修改字段、选中并删除行,保存前查看将要执行的 `updateOne` 和 `deleteOne` 语句,删除后表格会自动重新加载。结果表格不支持新增行,请使用 `insertOne` 或文档浏览器。聚合、带投影的查询以及多条命令的脚本保持只读,因为它们的行无法对应到单个文档。 + ## JSON 文档编辑 文档详情可以在字段树和完整 JSON 源码之间切换: diff --git a/docs/content/docs/mongodb.mdx b/docs/content/docs/mongodb.mdx index df4fe0b5c0..14eba1841f 100644 --- a/docs/content/docs/mongodb.mdx +++ b/docs/content/docs/mongodb.mdx @@ -68,6 +68,10 @@ The grid expands common fields into columns and supports: Collections with very different document shapes are better handled in the full-document view so hidden fields are not overlooked. +### Query Result Editing + +A query tab result grid is editable when a single `find` or `findOne` runs without a projection and returns `_id`. There you can edit fields and delete rows, review the pending `updateOne` and `deleteOne` statements before saving, and the grid reloads after a deletion. Inserting rows is not available; use `insertOne` or the document browser instead. Aggregations, projected queries, and multi-command scripts stay read-only because their rows cannot be mapped back to single documents. + ## JSON Document Editing Document details can switch between a field tree and full JSON source: