diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index cb6534acd..644923160 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -4764,6 +4764,7 @@ const { displayItems: visibleDisplayItems, sql: computed(() => props.sql), tableMeta: computed(() => (props.tableMeta ? { ...props.tableMeta } : undefined)), + copyInsertTargetLabel: computed(() => props.tableMeta?.tableName ?? props.customSaveHandler?.targetLabel), databaseType: computed(() => props.databaseType), connectionId: computed(() => props.connectionId), database: computed(() => props.database), diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 632b3a833..e076647c2 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -12,6 +12,7 @@ import { formatSqlInsert } from "@/lib/exportFormats"; import { uuid } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settingsStore"; import { expandNestedJsonStringsForCopy } from "@/lib/jsonCopyValue"; +import { buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongoDocumentValues"; import type { DatabaseType, QueryResult } from "@/types/database"; import type { QueryResultExportRequest } from "@/lib/api"; @@ -31,6 +32,7 @@ export interface UseDataGridExportOptions { displayItems: ComputedRef; sql: ComputedRef; tableMeta: ComputedRef; + copyInsertTargetLabel?: ComputedRef; databaseType: ComputedRef; connectionId: ComputedRef; database: ComputedRef; @@ -100,6 +102,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { displayItems, sql, tableMeta, + copyInsertTargetLabel, sourceColumns, databaseType, connectionId, @@ -192,6 +195,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { databaseType: databaseType.value ?? null, schema: tableMeta.value?.schema ?? null, tableName: tableMeta.value?.tableName ?? null, + copyInsertTargetLabel: copyInsertTargetLabel?.value ?? null, columns: columns.value, sourceColumns: sourceColumns.value ?? null, excludePrimaryKeys, @@ -238,14 +242,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); try { - const statement = await buildDataGridCopyInsertStatement({ - databaseType: databaseType.value, - tableMeta: tableMeta.value, - columns: columns.value, - sourceColumns: sourceColumns.value, - rows: rows.map((item) => item.data), - excludePrimaryKeys, - }); + const statement = + databaseType.value === "mongodb" + ? buildMongoCopyInsertStatement({ + collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection", + columns: columns.value, + sourceColumns: sourceColumns.value, + rows: rows.map((item) => item.data), + excludePrimaryKeys, + }) + : await buildDataGridCopyInsertStatement({ + databaseType: databaseType.value, + tableMeta: tableMeta.value, + columns: columns.value, + sourceColumns: sourceColumns.value, + rows: rows.map((item) => item.data), + excludePrimaryKeys, + }); const latest = insertCopyCache(excludePrimaryKeys); if (latest.key !== key) return; setInsertCopyCache(excludePrimaryKeys, { @@ -1093,6 +1106,17 @@ function replaceControlCharacters(value: string, replacement: string): string { .join(""); } +function buildMongoCopyInsertStatement(options: { collection: string; columns: string[]; sourceColumns?: Array; rows: CellValue[][]; excludePrimaryKeys?: boolean }): 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 collection = `db.getCollection(${JSON.stringify(options.collection)})`; + if (documents.length === 1) return `${collection}.insert(${formatMongoShellLiteral(documents[0])});`; + return `${collection}.insertMany(${formatMongoShellLiteral(documents)});`; +} + function compactLocalTimestamp(date = new Date()): string { const yy = String(date.getFullYear() % 100).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0"); diff --git a/apps/desktop/src/lib/mongoDocumentValues.ts b/apps/desktop/src/lib/mongoDocumentValues.ts index 78f26eb24..594a576c6 100644 --- a/apps/desktop/src/lib/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongoDocumentValues.ts @@ -2,6 +2,7 @@ export type MongoInputValue = string | number | boolean | null; const MONGO_SHELL_DATE_PATTERN = /^(?:ISODate|new Date)\(\s*(["'])(.+)\1\s*\)$/; const LEGACY_MONGO_DATE_DISPLAY_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?$/; +const MONGO_OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/; export function mongoShellDateToExtendedJson(value: unknown): unknown { if (typeof value !== "string") return value; @@ -67,6 +68,22 @@ export function buildMongoInsertDocument(row: MongoInputValue[], columns: string return doc; } +export function buildMongoCopyInsertDocument(row: MongoInputValue[], columns: string[], options: { excludePrimaryKeys?: boolean } = {}): Record { + const doc: Record = {}; + for (let ci = 0; ci < columns.length; ci++) { + const col = columns[ci]; + if (!col || (options.excludePrimaryKeys && col === "_id")) continue; + const val = row[ci]; + if (val === null) continue; + if (col === "_id" && typeof val === "string" && MONGO_OBJECT_ID_PATTERN.test(val)) { + doc[col] = { $oid: val }; + continue; + } + doc[col] = parseMongoDocumentInputValue(val); + } + return doc; +} + export function formatMongoShellLiteral(value: unknown): string { if (value === null || value === undefined) return "null"; if (typeof value === "number" || typeof value === "boolean") return String(value); @@ -78,6 +95,9 @@ export function formatMongoShellLiteral(value: unknown): string { if (keys.length === 1 && typeof object.$date === "string") { return `ISODate(${JSON.stringify(object.$date)})`; } + if (keys.length === 1 && typeof object.$oid === "string" && MONGO_OBJECT_ID_PATTERN.test(object.$oid)) { + return `ObjectId(${JSON.stringify(object.$oid)})`; + } return `{${keys.map((key) => `${JSON.stringify(key)}:${formatMongoShellLiteral(object[key])}`).join(",")}}`; } return JSON.stringify(String(value)); diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index df2ac5197..99b72c13f 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue } from "../../apps/desktop/src/lib/mongoDocumentValues.ts"; +import { buildMongoCopyInsertDocument, buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue } from "../../apps/desktop/src/lib/mongoDocumentValues.ts"; test("parses Mongo shell ISODate literals as extended JSON dates", () => { assert.deepEqual(parseMongoDocumentInputValue('ISODate("2026-06-10T13:59:31.287Z")'), { @@ -42,6 +42,32 @@ test("builds Mongo inserts with parsed date values", () => { }); }); +test("builds Mongo copy inserts with ObjectId and parsed document values", () => { + assert.deepEqual( + buildMongoCopyInsertDocument( + ["6743e4bfa3f6f84bc3fff6c8", "577", '{"endingBalance":{"beginningBalance":"0"},"Line":[]}', 'ISODate("2024-11-25T02:45:36.184Z")'], + ["_id", "accountId", "data", "lastUpdatedDate"], + ), + { + _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, + accountId: 577, + data: { + endingBalance: { + beginningBalance: "0", + }, + Line: [], + }, + lastUpdatedDate: { $date: "2024-11-25T02:45:36.184Z" }, + }, + ); +}); + +test("builds Mongo copy inserts without primary keys when requested", () => { + assert.deepEqual(buildMongoCopyInsertDocument(["6743e4bfa3f6f84bc3fff6c8", "done"], ["_id", "status"], { excludePrimaryKeys: true }), { + status: "done", + }); +}); + test("formats extended JSON dates as Mongo shell ISODate literals", () => { assert.equal( formatMongoShellLiteral({ @@ -52,3 +78,7 @@ test("formats extended JSON dates as Mongo shell ISODate literals", () => { '{"$set":{"createdAt":ISODate("2026-06-10T13:59:31.287Z")}}', ); }); + +test("formats extended JSON object ids as Mongo shell ObjectId literals", () => { + assert.equal(formatMongoShellLiteral({ $oid: "6743e4bfa3f6f84bc3fff6c8" }), 'ObjectId("6743e4bfa3f6f84bc3fff6c8")'); +}); diff --git a/packages/app-tests/useDataGridExport.test.ts b/packages/app-tests/useDataGridExport.test.ts index 85ed57a61..a42edd319 100644 --- a/packages/app-tests/useDataGridExport.test.ts +++ b/packages/app-tests/useDataGridExport.test.ts @@ -14,6 +14,7 @@ const apiMock = vi.hoisted(() => ({ exportQueryResultJson: vi.fn(), exportQueryResultMarkdown: vi.fn(), exportQueryResultsXlsx: vi.fn(), + buildDataGridCopyInsertStatement: vi.fn(), })); const clipboardMock = vi.hoisted(() => ({ copyToClipboard: vi.fn(), @@ -294,6 +295,90 @@ test("copy row JSON keeps nested JSON strings for non-MongoDB rows", async () => }); }); +test("copy MongoDB row as INSERT uses Mongo shell insert syntax", async () => { + const contextCell = ref({ rowId: 1, rowIndex: 0, col: 0 }); + const jsonString = '{"endingBalance":{"beginningBalance":"0","endingBalance":"100","endingDate":"2024-11-25"},"Line":[]}'; + const row = { + id: 1, + data: ["6743e4bfa3f6f84bc3fff6c8", "577", "done", jsonString, 'ISODate("2024-11-25T02:45:36.184Z")'], + isNew: false, + isDeleted: false, + isDirtyCol: [false, false, false, false, false], + status: "", + }; + const composable = useDataGridExport({ + columns: computed(() => ["_id", "accountId", "status", "data", "lastUpdatedDate"]), + displayItems: computed(() => [row]), + sql: computed(() => undefined), + tableMeta: computed(() => undefined), + copyInsertTargetLabel: computed(() => "accounting_reconciliations"), + databaseType: computed(() => "mongodb"), + connectionId: computed(() => "conn-1"), + database: computed(() => "db"), + context: computed(() => "results"), + sourceColumns: computed(() => undefined), + columnTypes: computed(() => undefined), + whereInput: computed(() => undefined), + orderBy: computed(() => undefined), + exportBatchSize: computed(() => 1000), + hasCellSelection: computed(() => false), + selectedCells: computed(() => ({ columns: [], rows: [] })), + selectedRange: computed(() => null), + contextCell, + getRowItem: () => row, + selectedRowIds: ref(new Set()), + hasRowSelection: computed(() => false), + }); + + await composable.prefetchRowAsInsertStatement(false); + await composable.copyRowAsInsert(); + + assert.equal(apiMock.buildDataGridCopyInsertStatement.mock.calls.length, 0); + assert.equal( + clipboardMock.copyToClipboard.mock.calls[0][0], + 'db.getCollection("accounting_reconciliations").insert({"_id":ObjectId("6743e4bfa3f6f84bc3fff6c8"),"accountId":577,"status":"done","data":{"endingBalance":{"beginningBalance":"0","endingBalance":"100","endingDate":"2024-11-25"},"Line":[]},"lastUpdatedDate":ISODate("2024-11-25T02:45:36.184Z")});', + ); +}); + +test("copy MongoDB rows as INSERT excludes _id for insert without primary keys", async () => { + const selectedRowIds = ref(new Set([1, 2])); + const rows = [ + { id: 1, data: ["6743e4bfa3f6f84bc3fff6c8", "done"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" }, + { id: 2, data: ["6743e4bfa3f6f84bc3fff6c9", "draft"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" }, + ]; + const composable = useDataGridExport({ + columns: computed(() => ["_id", "status"]), + displayItems: computed(() => rows), + sql: computed(() => undefined), + tableMeta: computed(() => ({ + tableName: "accounting_reconciliations", + primaryKeys: ["_id"], + })), + databaseType: computed(() => "mongodb"), + connectionId: computed(() => "conn-1"), + database: computed(() => "db"), + context: computed(() => "results"), + sourceColumns: computed(() => undefined), + columnTypes: computed(() => undefined), + whereInput: computed(() => undefined), + orderBy: computed(() => undefined), + exportBatchSize: computed(() => 1000), + hasCellSelection: computed(() => false), + selectedCells: computed(() => ({ columns: [], rows: [] })), + selectedRange: computed(() => null), + contextCell: ref(null), + getRowItem: (rowId: number) => rows.find((item) => item.id === rowId), + selectedRowIds, + hasRowSelection: computed(() => true), + }); + + await composable.prefetchRowAsInsertStatement(true); + await composable.copyRowAsInsertWithoutPrimaryKeys(); + + assert.equal(apiMock.buildDataGridCopyInsertStatement.mock.calls.length, 0); + assert.equal(clipboardMock.copyToClipboard.mock.calls[0][0], 'db.getCollection("accounting_reconciliations").insertMany([{"status":"done"},{"status":"draft"}]);'); +}); + test("default data grid export file names use sanitized base names and compact local timestamps", () => { vi.useFakeTimers(); try {