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 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<string, unknown>)._id ?? fallback;
|
||||
}
|
||||
const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() => {
|
||||
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<CustomSaveHandler["applySavedChanges"]> = ({ dirtyRows, columns }) => {
|
||||
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 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)})`;
|
||||
}
|
||||
Reference in new issue
Block a user