feat(mongodb): delete rows from the query result grid
This commit is contained in:
1 parent
d1bd67b828
commit
9fa61d53b6
5 files changed
+183
-35
No files matched your search
@@ -172,7 +172,8 @@ import { elasticsearchProfileBodyForResult, parseElasticsearchProfile } from "@/
|
|||||||
import * as api from "@/lib/backend/api";
|
import * as api from "@/lib/backend/api";
|
||||||
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||||
import { queryResultExportBaseName } from "@/lib/export/saveTextFile";
|
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 type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
||||||
import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
|
import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
|
||||||
import { useTabScroll } from "@/composables/useTabScroll";
|
import { useTabScroll } from "@/composables/useTabScroll";
|
||||||
@@ -632,50 +633,30 @@ type MongoQueryGridChanges = {
|
|||||||
columns: string[];
|
columns: string[];
|
||||||
rows: MongoInputValue[][];
|
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<string, unknown>)._id ?? fallback;
|
|
||||||
}
|
|
||||||
const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() => {
|
const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() => {
|
||||||
const tab = props.activeTab;
|
const tab = props.activeTab;
|
||||||
const target = tab.mongoEditTarget;
|
const target = tab.mongoEditTarget;
|
||||||
if (tab.mode !== "query" || activeEffectiveDatabaseType.value !== "mongodb" || !target || !activeResultConnectionId.value || !activeResultDatabase.value || !tab.result) return undefined;
|
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;
|
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) => {
|
const save: CustomSaveHandler["save"] = async (changes: MongoQueryGridChanges) => {
|
||||||
if (changes.newRows.length > 0 || changes.deletedRows.size > 0) {
|
if (changes.newRows.length > 0) {
|
||||||
throw new Error("MongoDB query result editing only supports updating existing rows.");
|
throw new Error("MongoDB query result editing does not support inserting rows.");
|
||||||
}
|
}
|
||||||
const idColIdx = changes.columns.indexOf(target.idColumn);
|
if (!changes.columns.includes(target.idColumn)) throw new Error("No _id column");
|
||||||
if (idColIdx < 0) throw new Error("No _id column");
|
for (const operation of buildMongoQueryResultOperations(changes, editTarget)) {
|
||||||
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
|
const id = serializeMongoDocumentId(operation.id);
|
||||||
const row = changes.rows[rowIdx];
|
if (operation.kind === "delete") {
|
||||||
const id = row?.[idColIdx];
|
await api.mongoDeleteDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, id);
|
||||||
if (id === null || id === undefined || String(id).trim() === "") continue;
|
} else {
|
||||||
const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns, tab.result?.mongo_documents?.[rowIdx]);
|
await api.mongoUpdateDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, id, JSON.stringify(operation.update));
|
||||||
if (Object.keys(updateDoc).length === 0) continue;
|
}
|
||||||
await api.mongoUpdateDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, serializeMongoDocumentId(mongoQueryResultDocumentId(rowIdx, id)), JSON.stringify(updateDoc));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const preview: CustomSaveHandler["preview"] = async (changes: MongoQueryGridChanges) => {
|
const preview: CustomSaveHandler["preview"] = async (changes: MongoQueryGridChanges) => buildMongoQueryResultOperations(changes, editTarget).map((operation) => formatMongoQueryResultOperationPreview(target.collection, operation));
|
||||||
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 applySavedChanges: NonNullable<CustomSaveHandler["applySavedChanges"]> = ({ dirtyRows, columns }) => {
|
const applySavedChanges: NonNullable<CustomSaveHandler["applySavedChanges"]> = ({ dirtyRows, columns }) => {
|
||||||
const documents = tab.result?.mongo_documents;
|
const documents = tab.result?.mongo_documents;
|
||||||
@@ -702,7 +683,7 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 resultsPaneOpen = ref(false);
|
||||||
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);
|
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);
|
||||||
|
|||||||
@@ -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> = {}): 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<number, Map<number, MongoInputValue>> {
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||||
|
|
||||||
|
export interface MongoQueryResultGridChanges {
|
||||||
|
dirtyRows: Map<number, Map<number, MongoInputValue>>;
|
||||||
|
deletedRows: Set<number>;
|
||||||
|
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<string, unknown> } | { 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<string, unknown>)._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)})`;
|
||||||
|
}
|
||||||
@@ -68,6 +68,10 @@ db.orders.aggregate([
|
|||||||
|
|
||||||
字段结构差异很大的 Collection 更适合使用完整文档视图,避免隐藏字段在表格列中被忽略。
|
字段结构差异很大的 Collection 更适合使用完整文档视图,避免隐藏字段在表格列中被忽略。
|
||||||
|
|
||||||
|
### 查询结果编辑
|
||||||
|
|
||||||
|
当查询标签中只有一条不带投影的 `find` 或 `findOne`、且结果包含 `_id` 时,结果表格可以直接编辑:可以修改字段、选中并删除行,保存前查看将要执行的 `updateOne` 和 `deleteOne` 语句,删除后表格会自动重新加载。结果表格不支持新增行,请使用 `insertOne` 或文档浏览器。聚合、带投影的查询以及多条命令的脚本保持只读,因为它们的行无法对应到单个文档。
|
||||||
|
|
||||||
## JSON 文档编辑
|
## JSON 文档编辑
|
||||||
|
|
||||||
文档详情可以在字段树和完整 JSON 源码之间切换:
|
文档详情可以在字段树和完整 JSON 源码之间切换:
|
||||||
|
|||||||
@@ -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.
|
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
|
## JSON Document Editing
|
||||||
|
|
||||||
Document details can switch between a field tree and full JSON source:
|
Document details can switch between a field tree and full JSON source:
|
||||||
|
|||||||
Reference in new issue
Block a user