feat(mongodb): support copying MongoDB documents

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-24 15:50:49 +08:00
1 parent b8c488b398
commit 0992c52448
19 files changed
+580 -43

No files matched your search

@@ -56,13 +56,24 @@ function row(data: unknown[]) {
};
}
function createMongoExportState(options: { columns: string[]; item: ReturnType<typeof row> & { sourceIndex: number }; mongoDocuments: unknown[]; selectedCellMatrix?: CellSelectionMatrix }) {
function createMongoExportState(options: {
columns: string[];
item: ReturnType<typeof row> & { sourceIndex: number };
items?: Array<ReturnType<typeof row> & { sourceIndex: number }>;
mongoDocuments: unknown[];
selectedCellMatrix?: CellSelectionMatrix;
selectedRowIds?: Set<number>;
mongoUpdateTarget?: false;
}) {
const items = options.items ?? [options.item];
const selectedRowIds = options.selectedRowIds ?? new Set<number>();
const state: UseDataGridExportOptions = {
columns: computed(() => options.columns),
displayItems: computed(() => [options.item]),
displayItems: computed(() => items),
sql: computed(() => undefined),
tableMeta: computed(() => undefined),
copyInsertTargetLabel: computed(() => "documents"),
mongoUpdateTarget: computed(() => (options.mongoUpdateTarget === false ? undefined : { collection: "documents", idColumn: "_id" })),
databaseType: computed(() => "mongodb"),
connectionId: computed(() => "connection-1"),
database: computed(() => "dbx"),
@@ -78,9 +89,9 @@ function createMongoExportState(options: { columns: string[]; item: ReturnType<t
selectedCellMatrix: computed(() => options.selectedCellMatrix ?? null),
selectedRange: computed(() => null),
contextCell: ref({ rowId: options.item.id, rowIndex: 0, col: -1 }),
getRowItem: (rowId) => (rowId === options.item.id ? options.item : undefined),
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
getRowItem: (rowId) => items.find((item) => item.id === rowId),
selectedRowIds: ref(selectedRowIds),
hasRowSelection: computed(() => selectedRowIds.size > 0),
};
return useDataGridExport(state);
}
@@ -337,6 +348,84 @@ describe("useDataGridExport prepared row statements", () => {
});`);
});
it("copies a Mongo row as updateOne while preserving BSON types and missing fields", async () => {
const item = {
...row(["507f1f77bcf86cd799439011", "123", 'NumberLong("9007199254740993")', null, null, '{"role":"maintainer"}', "12.34", "AQI="]),
sourceIndex: 0,
};
item.isDirtyCol = [false, false, false, false, false, true, false, false];
const state = createMongoExportState({
columns: ["_id", "numericText", "counter", "nullable", "missing", "profile", "decimal", "payload"],
item,
mongoDocuments: [
{
_id: { $oid: "507f1f77bcf86cd799439011" },
numericText: "123",
counter: { $numberLong: "9007199254740993" },
nullable: null,
profile: { role: "admin" },
decimal: { $numberDecimal: "12.34" },
payload: { $binary: { base64: "AQI=", subType: "00" } },
},
],
});
expect(state.canCopyRowAsUpdate.value).toBe(true);
await state.copyRowAsUpdate();
expect(buildDataGridCopyUpdateStatements).not.toHaveBeenCalled();
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
expect(copied).toContain('db.getCollection("documents")');
expect(copied).toContain(".updateOne(");
expect(copied).toContain('"_id": ObjectId("507f1f77bcf86cd799439011")');
expect(copied).toContain('"numericText": "123"');
expect(copied).toContain('"counter": NumberLong("9007199254740993")');
expect(copied).toContain('"nullable": null');
expect(copied).toContain('"profile":');
expect(copied).toContain('"role": "maintainer"');
expect(copied).toContain('"decimal": EJSON.deserialize(');
expect(copied).toContain('"$numberDecimal": "12.34"');
expect(copied).toContain('"payload": EJSON.deserialize(');
expect(copied).toContain('"$binary":');
expect(copied).toContain('"$unset":');
expect(copied).toContain('"missing": ""');
});
it("does not expose Mongo UPDATE copy without an explicit data-list target", async () => {
const item = { ...row(["507f1f77bcf86cd799439011", "Alice"]), sourceIndex: 0 };
const state = createMongoExportState({
columns: ["_id", "name"],
item,
mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }],
mongoUpdateTarget: false,
});
expect(state.canCopyRowAsUpdate.value).toBe(false);
await state.copyRowAsUpdate();
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("copies selected Mongo rows as separate updates using each sorted source document", async () => {
const first = { ...row(["second-id", "Second"]), id: 1, sourceIndex: 1 };
const second = { ...row(["first-id", "First"]), id: 2, sourceIndex: 0 };
const state = createMongoExportState({
columns: ["_id", "name"],
item: first,
items: [first, second],
mongoDocuments: [
{ _id: "first-id", name: "First" },
{ _id: "second-id", name: "Second" },
],
selectedRowIds: new Set([1, 2]),
});
await state.copyRowAsUpdate();
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
expect(copied.match(/\.updateOne\(/g)).toHaveLength(2);
expect(copied.indexOf('"_id": "second-id"')).toBeLessThan(copied.indexOf('"_id": "first-id"'));
});
it("preserves original Mongo types while limiting INSERT to the selected fields", async () => {
const item = { ...row(["123", "true", '{"kind":"literal"}']), sourceIndex: 0 };
const state = createMongoExportState({
@@ -13,7 +13,7 @@ import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats";
import { uuid } from "@/lib/common/utils";
import { useSettingsStore } from "@/stores/settingsStore";
import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue";
import { buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, buildMongoCopyUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { QueryResultExportRequest } from "@/lib/backend/api";
@@ -51,6 +51,11 @@ interface RowItem {
status: string;
}
export interface MongoCopyUpdateTarget {
collection: string;
idColumn: "_id";
}
export interface UseDataGridExportOptions {
columns: ComputedRef<string[]>;
displayItems: ComputedRef<RowItem[]>;
@@ -58,6 +63,7 @@ export interface UseDataGridExportOptions {
exportSql?: ComputedRef<string | undefined>;
tableMeta: ComputedRef<DataGridTableMeta | undefined>;
copyInsertTargetLabel?: ComputedRef<string | undefined>;
mongoUpdateTarget?: ComputedRef<MongoCopyUpdateTarget | undefined>;
databaseType: ComputedRef<DatabaseType | undefined>;
connectionId: ComputedRef<string | undefined>;
database: ComputedRef<string | undefined>;
@@ -181,6 +187,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportSql: resultExportSql,
tableMeta,
copyInsertTargetLabel,
mongoUpdateTarget,
sourceColumns,
databaseType,
connectionId,
@@ -328,6 +335,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
}
function buildMongoCopyUpdateStatements(rows: RowItem[], target: MongoCopyUpdateTarget): string[] {
const documents = options.mongoDocuments?.value;
if (!documents) return [];
const copyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? "");
const statements: string[] = [];
for (const item of rows) {
if (item.sourceIndex === undefined) continue;
const originalDocument = documents[item.sourceIndex];
if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) continue;
const source = originalDocument as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(source, target.idColumn)) continue;
const update = buildMongoCopyUpdateDocument(item.data as MongoInputValue[], copyColumns, item.isDirtyCol, originalDocument, target.idColumn);
if (!update) continue;
const statement = `db.getCollection(${JSON.stringify(target.collection)}).updateOne({${JSON.stringify(target.idColumn)}:${formatMongoShellLiteral(source[target.idColumn])}},${formatMongoShellLiteral(update)});`;
statements.push(formatMongoCopyStatement(statement) ?? statement);
}
return statements;
}
function insertCopyKey(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): string {
return copyInsertKey(
{
@@ -426,7 +455,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function buildCopyInsertStatement(data: CopyInsertData, excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): Promise<string | undefined> {
if (databaseType.value === "mongodb") {
return formatMongoCopyInsertStatement(
return formatMongoCopyStatement(
buildMongoCopyInsertStatement({
collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection",
columns: data.columns,
@@ -633,8 +662,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function prepareRowAsUpdateStatement(): Promise<string | undefined> {
const currentTableMeta = tableMeta.value;
if (!currentTableMeta?.primaryKeys.length) {
const rows = updateEligibleRows();
if (!rows.length) {
setUpdateCopyCache({
key: "",
text: "",
@@ -643,8 +672,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
return;
}
const rows = updateEligibleRows();
if (!rows.length) {
if (databaseType.value === "mongodb") {
const target = mongoUpdateTarget?.value;
if (!target) return;
await yieldToMainThread();
const text = buildMongoCopyUpdateStatements(rows, target).join("\n");
return text || undefined;
}
const currentTableMeta = tableMeta.value;
if (!currentTableMeta?.primaryKeys.length) {
setUpdateCopyCache({
key: "",
text: "",
@@ -831,11 +869,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
const canCopyRowAsUpdate = computed(() => {
if (!tableMeta.value?.primaryKeys.length) return false;
const rows = updateEligibleRows();
if (!rows.length) return false;
if (databaseType.value === "neo4j" || databaseType.value === "tdengine") return false;
const saveColumns = effectiveColumns(sourceColumns.value, columns.value);
if (databaseType.value === "mongodb") {
const target = mongoUpdateTarget?.value;
if (!target || !options.mongoDocuments?.value || rows.some((item) => item.sourceIndex === undefined)) return false;
if (findColumnIndex(saveColumns, target.idColumn) === -1) return false;
return saveColumns.some((column) => column && normalizeColumnName(column) !== normalizeColumnName(target.idColumn));
}
if (!tableMeta.value?.primaryKeys.length) return false;
if (databaseType.value === "neo4j" || databaseType.value === "tdengine") return false;
const primaryKeys = tableMeta.value.primaryKeys;
if (primaryKeys.some((primaryKey) => findColumnIndex(saveColumns, primaryKey) === -1)) return false;
const primaryKeySet = new Set(primaryKeys.map(normalizeColumnName));
@@ -1589,7 +1633,7 @@ function buildMongoCopyInsertStatement(options: { collection: string; columns: s
return `${collection}.insertMany(${formatMongoShellLiteral(documents)});`;
}
function formatMongoCopyInsertStatement(statement: string | undefined): string | undefined {
function formatMongoCopyStatement(statement: string | undefined): string | undefined {
if (!statement) return undefined;
try {
return formatMongoShellText(statement);
@@ -133,6 +133,7 @@ export function useSidebarDataOpenRuntime() {
tab.resultSortMode = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.resultSortedSql = undefined;
tab.resultPageSql = undefined;
tab.resultPageLimit = undefined;