fix(mongodb): preserve document types when copying data

This commit is contained in:
zipg 2026-07-13 16:15:02 +08:00 committed by GitHub
parent cc9b802b49
commit c2c4860a28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 159 additions and 11 deletions

View File

@ -168,7 +168,7 @@ const gridResult = computed<QueryResult>(() => {
}),
);
return { columns, rows, affected_rows: 0, execution_time_ms: 0, truncated: false };
return { columns, rows, mongo_documents: docs, affected_rows: 0, execution_time_ms: 0, truncated: false };
});
const documentFilterFieldOptions = computed(() => gridResult.value.columns);
const documentStructuredFilterCount = computed(() => (appliedDocumentFilter.value ? 1 : 0));
@ -421,6 +421,7 @@ const customSaveHandler = computed<CustomSaveHandler>(() => ({
preview: previewDocumentChanges,
supportsInsert: true,
readonlyColumns: documentStoreProvider.value.kind === "elasticsearch" ? ["_routing"] : undefined,
targetLabel: props.collection,
}));
function stopDocumentLoadingTimer() {
@ -943,6 +944,7 @@ function resetTableSearchSplitWidth() {
class="flex-1 min-h-0"
:result="gridResult"
context="results"
:database-type="props.databaseType"
editable
:custom-save-handler="customSaveHandler"
:loading="loading"

View File

@ -6504,6 +6504,7 @@ const {
database: computed(() => props.executionDatabase ?? props.database),
context: computed(() => props.context),
sourceColumns: visibleSourceColumns,
mongoDocuments: computed(() => props.result.mongo_documents),
columnTypes: visibleColumnTypes,
whereInput: computed(() => currentWhereInput()),
orderBy: computed(() => currentOrderBy()),

View File

@ -55,6 +55,34 @@ function row(data: unknown[]) {
};
}
function createMongoExportState(options: { columns: string[]; item: ReturnType<typeof row> & { sourceIndex: number }; mongoDocuments: unknown[] }) {
const state: UseDataGridExportOptions = {
columns: computed(() => options.columns),
displayItems: computed(() => [options.item]),
sql: computed(() => undefined),
tableMeta: computed(() => undefined),
copyInsertTargetLabel: computed(() => "documents"),
databaseType: computed(() => "mongodb"),
connectionId: computed(() => "connection-1"),
database: computed(() => "dbx"),
context: computed(() => "results"),
sourceColumns: computed(() => options.columns),
mongoDocuments: computed(() => options.mongoDocuments),
columnTypes: computed(() => undefined),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
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),
};
return useDataGridExport(state);
}
function createExportState(tableMeta: DataGridTableMeta, columns = tableMeta.columns?.map((column) => column.name) ?? ["id", "name"]) {
const item = row(columns.map((column, index) => (column === "id" ? 1 : `value-${index}`)));
const options: UseDataGridExportOptions = {
@ -166,4 +194,42 @@ describe("useDataGridExport prepared row statements", () => {
expect(toast).toHaveBeenCalledWith("grid.copyFailed: update builder unavailable", 5000);
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("copies Mongo JSON from the original document using the sorted source index and visible columns", async () => {
const item = { ...row(["true", '{"role":"admin"}']), sourceIndex: 1 };
const state = createMongoExportState({
columns: ["booleanText", "profile"],
item,
mongoDocuments: [
{ booleanText: "wrong row", profile: { role: "viewer" } },
{ booleanText: "true", profile: { role: "admin" }, hidden: "not selected" },
],
});
await state.copyRow();
expect(copyToClipboard).toHaveBeenCalledWith(JSON.stringify({ booleanText: "true", profile: { role: "admin" } }, null, 2));
});
it("preserves original Mongo string types in INSERT and applies explicit edits", async () => {
const item = { ...row(["123", "true", '{"kind":"literal"}', "2024-01-01 00:00:00", '{"role":"maintainer"}']), sourceIndex: 0 };
item.isDirtyCol = [false, false, false, false, true];
const state = createMongoExportState({
columns: ["numericText", "booleanText", "jsonText", "dateText", "profile"],
item,
mongoDocuments: [
{
numericText: "123",
booleanText: "true",
jsonText: '{"kind":"literal"}',
dateText: "2024-01-01 00:00:00",
profile: { role: "admin" },
},
],
});
await state.copyRowAsInsert();
expect(copyToClipboard).toHaveBeenCalledWith('db.getCollection("documents").insert({"numericText":"123","booleanText":"true","jsonText":"{\\"kind\\":\\"literal\\"}","dateText":"2024-01-01 00:00:00","profile":{"role":"maintainer"}});');
});
});

View File

@ -12,7 +12,7 @@ import { formatSqlInsert } from "@/lib/export/exportFormats";
import { uuid } from "@/lib/common/utils";
import { useSettingsStore } from "@/stores/settingsStore";
import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue";
import { buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { QueryResultExportRequest } from "@/lib/backend/api";
import { DBX_ROWID_COLUMN } from "@/lib/table/tableEditing";
@ -40,6 +40,7 @@ export interface UseDataGridExportOptions {
database: ComputedRef<string | undefined>;
context: ComputedRef<"results" | "table-data" | undefined>;
sourceColumns: ComputedRef<Array<string | undefined> | undefined>;
mongoDocuments?: ComputedRef<unknown[] | undefined>;
columnTypes: ComputedRef<Array<string | undefined> | undefined>;
whereInput: ComputedRef<string | undefined>;
orderBy: ComputedRef<string | undefined>;
@ -233,7 +234,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
function insertCopyKey(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): string {
const rows = copyStatementRowsKey(insertEligibleRows());
const eligibleRows = insertEligibleRows();
const rows = copyStatementRowsKey(eligibleRows);
const originalMongoDocuments = eligibleRows.map((item) => (item.sourceIndex === undefined ? undefined : options.mongoDocuments?.value?.[item.sourceIndex]));
return JSON.stringify({
databaseType: databaseType.value ?? null,
schema: tableMeta.value?.schema ?? null,
@ -245,12 +248,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
excludePrimaryKeys,
insertMode,
rows,
originalMongoDocuments,
});
}
function copyStatementRowsKey(rows: RowItem[]): Array<{ id: number; data: CellValue[] }> {
function copyStatementRowsKey(rows: RowItem[]): Array<{ id: number; sourceIndex?: number; data: CellValue[]; isDirtyCol: boolean[] }> {
// Prepared copy SQL depends on current cell values; edited rows keep the same id while their data changes.
return rows.map((item) => ({ id: item.id, data: item.data }));
return rows.map((item) => ({ id: item.id, sourceIndex: item.sourceIndex, data: item.data, isDirtyCol: item.isDirtyCol }));
}
function insertCopyCache(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): CopyStatementCache {
@ -309,7 +313,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection",
columns: columns.value,
sourceColumns: sourceColumns.value,
rows: rows.map((item) => item.data),
rows,
mongoDocuments: options.mongoDocuments?.value,
excludePrimaryKeys,
insertMode,
})
@ -516,6 +521,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
function rowToJsonObject(item: RowItem): Record<string, unknown> {
if (options.databaseType.value === "mongodb" && item.sourceIndex !== undefined) {
const original = options.mongoDocuments?.value?.[item.sourceIndex];
const document = buildMongoCopyDocumentFromOriginal(original, item.data as MongoInputValue[], columns.value, item.isDirtyCol);
if (document) return document;
}
const obj: Record<string, unknown> = {};
columns.value.forEach((col, i) => {
obj[col] = item.data[i];
@ -526,7 +536,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copyRowsAsJson(items: RowItem[]) {
if (items.length === 0) return;
const value = items.length === 1 ? rowToJsonObject(items[0]) : items.map(rowToJsonObject);
const copyValue = options.databaseType.value === "mongodb" ? expandNestedJsonStringsForCopy(value) : value;
const hasOriginalMongoDocuments = options.databaseType.value === "mongodb" && items.every((item) => item.sourceIndex !== undefined && options.mongoDocuments?.value?.[item.sourceIndex] !== undefined);
const copyValue = options.databaseType.value === "mongodb" && !hasOriginalMongoDocuments ? expandNestedJsonStringsForCopy(value) : value;
await copyText(JSON.stringify(copyValue, null, 2));
}
@ -1241,12 +1252,17 @@ function replaceControlCharacters(value: string, replacement: string): string {
.join("");
}
function buildMongoCopyInsertStatement(options: { collection: string; columns: string[]; sourceColumns?: Array<string | undefined>; rows: CellValue[][]; excludePrimaryKeys?: boolean; insertMode?: DataGridCopyInsertMode }): string | undefined {
function buildMongoCopyInsertStatement(options: { collection: string; columns: string[]; sourceColumns?: Array<string | undefined>; rows: RowItem[]; mongoDocuments?: unknown[]; excludePrimaryKeys?: boolean; insertMode?: DataGridCopyInsertMode }): string | undefined {
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const columnIndexes = saveColumns.map((column, index) => ({ column, index })).filter((item): item is { column: string; index: number } => !!item.column);
if (columnIndexes.length === 0 || options.rows.length === 0) return undefined;
const documentColumns = columnIndexes.map((item) => item.column);
const documents = options.rows.map((row) => buildMongoCopyInsertDocument(columnIndexes.map((item) => row[item.index]) as MongoInputValue[], documentColumns, { excludePrimaryKeys: options.excludePrimaryKeys }));
const documents = options.rows.map((item) => {
const row = columnIndexes.map(({ index }) => item.data[index]) as MongoInputValue[];
const dirtyColumns = columnIndexes.map(({ index }) => item.isDirtyCol[index] ?? false);
const original = item.sourceIndex === undefined ? undefined : options.mongoDocuments?.[item.sourceIndex];
return buildMongoCopyDocumentFromOriginal(original, row, documentColumns, dirtyColumns, { excludePrimaryKeys: options.excludePrimaryKeys }) ?? buildMongoCopyInsertDocument(row, documentColumns, { excludePrimaryKeys: options.excludePrimaryKeys });
});
const collection = `db.getCollection(${JSON.stringify(options.collection)})`;
if (documents.length === 1) return `${collection}.insert(${formatMongoShellLiteral(documents[0])});`;
if (options.insertMode === "row-by-row") {

View File

@ -100,6 +100,26 @@ export function buildMongoCopyInsertDocument(row: MongoInputValue[], columns: st
return doc;
}
export function buildMongoCopyDocumentFromOriginal(original: unknown, row: MongoInputValue[], columns: string[], dirtyColumns: boolean[], options: { excludePrimaryKeys?: boolean } = {}): Record<string, unknown> | null {
if (!original || typeof original !== "object" || Array.isArray(original)) return null;
const source = original as Record<string, unknown>;
const document: Record<string, unknown> = {};
for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
const column = columns[columnIndex];
if (!column || (options.excludePrimaryKeys && column === "_id")) continue;
// Display strings are ambiguous, so only explicitly edited cells may replace original BSON values.
if (dirtyColumns[columnIndex]) {
const value = row[columnIndex];
if (value !== null) document[column] = parseMongoDocumentInputValue(value);
continue;
}
if (Object.prototype.hasOwnProperty.call(source, column)) document[column] = source[column];
}
return document;
}
export function formatMongoShellLiteral(value: unknown): string {
if (value === null || value === undefined) return "null";
if (typeof value === "number" || typeof value === "boolean") return String(value);

View File

@ -5,15 +5,25 @@ import { test } from "vitest";
function searchBarSlotSource(): string {
const source = readFileSync(path.resolve("apps/desktop/src/components/document/DocumentBrowser.vue"), "utf8");
const start = source.indexOf('<template #search-bar');
const start = source.indexOf("<template #search-bar");
const end = source.indexOf("\n </DataGrid>", start);
assert.notEqual(start, -1, "expected DocumentBrowser search-bar slot");
assert.notEqual(end, -1, "expected DocumentBrowser DataGrid closing tag");
return source.slice(start, end);
}
function documentBrowserSource(): string {
return readFileSync(path.resolve("apps/desktop/src/components/document/DocumentBrowser.vue"), "utf8");
}
test("mongo document result search bar does not render a duplicate refresh button", () => {
const slot = searchBarSlotSource();
assert.equal(slot.includes('{{ t("grid.refresh") }}'), false);
assert.equal(slot.includes("RefreshCcw"), false);
});
test("mongo document table passes copy context to the data grid", () => {
const source = documentBrowserSource();
assert.match(source, /<DataGrid[\s\S]*?:database-type="props\.databaseType"[\s\S]*?<\/DataGrid>/);
assert.match(source, /const customSaveHandler = computed<CustomSaveHandler>\(\(\) => \(\{[\s\S]*?targetLabel: props\.collection,[\s\S]*?\}\)\);/);
});

View File

@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { applyMongoGridChangesToDocument, buildMongoCopyInsertDocument, buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue } from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts";
import { applyMongoGridChangesToDocument, buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue } from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts";
test("parses Mongo shell ISODate literals as extended JSON dates", () => {
assert.deepEqual(parseMongoDocumentInputValue('ISODate("2026-06-10T13:59:31.287Z")'), {
@ -88,6 +88,39 @@ test("builds Mongo copy inserts without primary keys when requested", () => {
});
});
test("projects original Mongo values without guessing types", () => {
const original = {
_id: { $oid: "6743e4bfa3f6f84bc3fff6c8" },
numericText: "123",
booleanText: "true",
jsonText: '{"kind":"literal"}',
dateText: "2024-01-01 00:00:00",
profile: { role: "admin" },
hidden: "not selected",
};
assert.deepEqual(
buildMongoCopyDocumentFromOriginal(original, ["ignored", "ignored", "ignored", "ignored", "ignored"], ["numericText", "booleanText", "jsonText", "dateText", "profile"], [false, false, false, false, false]),
{
numericText: "123",
booleanText: "true",
jsonText: '{"kind":"literal"}',
dateText: "2024-01-01 00:00:00",
profile: { role: "admin" },
},
);
});
test("applies only explicit Mongo grid edits to copied original documents", () => {
assert.deepEqual(
buildMongoCopyDocumentFromOriginal({ _id: "1", count: "123", profile: { role: "admin" } }, ["1", "456", '{"role":"maintainer"}'], ["_id", "count", "profile"], [false, true, false], { excludePrimaryKeys: true }),
{
count: 456,
profile: { role: "admin" },
},
);
});
test("formats extended JSON dates as Mongo shell ISODate literals", () => {
assert.equal(
formatMongoShellLiteral({