From 918c2fdf7a91ed071c22639d58e122c8ab76c5e5 Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 2 Aug 2026 19:09:22 +0800 Subject: [PATCH] fix(mongodb): honor selected columns in update copy --- .../__tests__/useDataGridExport.spec.ts | 108 ++++++++++++++++++ .../src/composables/useDataGridExport.ts | 52 ++++++++- .../src/composables/useDataGridExtractor.ts | 16 +-- 3 files changed, 164 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index fd6f751d7..676987956 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -691,4 +691,112 @@ describe("useDataGridExport prepared row statements", () => { ); expect(extractDataGridSelection).not.toHaveBeenCalled(); }); + + it("uses the Mongo update formatter for SQL Updates", async () => { + const item = { ...row(['ObjectId("507f1f77bcf86cd799439011")', "Alice"]), sourceIndex: 0 }; + const state = createMongoExportState({ + columns: ["_id", "name"], + item, + mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }], + selectedCellMatrix: { + rowIndexes: [0], + columnIndexes: [0, 1], + columns: ["_id", "name"], + rows: [[item.data[0], item.data[1]]], + }, + }); + + expect(state.canCopyWithExtractor("sql-updates")).toBe(true); + await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(true); + + 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('"name": "Alice"'); + expect(extractDataGridSelection).not.toHaveBeenCalled(); + }); + + it("updates only explicitly selected Mongo fields while keeping _id as the filter", async () => { + const item = { ...row(['ObjectId("507f1f77bcf86cd799439011")', "Alice", "active"]), sourceIndex: 0 }; + const state = createMongoExportState({ + columns: ["_id", "name", "status"], + item, + mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice", status: "active" }], + selectedCellMatrix: { + rowIndexes: [0], + columnIndexes: [1], + columns: ["name"], + rows: [[item.data[1]]], + }, + }); + + expect(state.canCopyWithExtractor("sql-updates")).toBe(true); + await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(true); + + const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? ""; + expect(copied).toContain('"_id": ObjectId("507f1f77bcf86cd799439011")'); + expect(copied).toContain('"name": "Alice"'); + expect(copied).not.toContain('"status"'); + }); + + it("does not expose Mongo SQL Updates for an _id-only selection", async () => { + const item = { ...row(['ObjectId("507f1f77bcf86cd799439011")', "Alice"]), sourceIndex: 0 }; + const state = createMongoExportState({ + columns: ["_id", "name"], + item, + mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }], + selectedCellMatrix: { + rowIndexes: [0], + columnIndexes: [0], + columns: ["_id"], + rows: [[item.data[0]]], + }, + }); + + expect(state.canCopyWithExtractor("sql-updates")).toBe(false); + await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(false); + expect(copyToClipboard).not.toHaveBeenCalled(); + }); + + it("filters new and deleted Mongo rows from SQL Updates", async () => { + const current = { ...row(['ObjectId("507f1f77bcf86cd799439011")', "Alice"]), id: 1, sourceIndex: 0 }; + const added = { ...row(['ObjectId("507f1f77bcf86cd799439012")', "New"]), id: 2, sourceIndex: 1, isNew: true }; + const deleted = { ...row(['ObjectId("507f1f77bcf86cd799439013")', "Deleted"]), id: 3, sourceIndex: 2, isDeleted: true }; + const state = createMongoExportState({ + columns: ["_id", "name"], + item: current, + items: [current, added, deleted], + mongoDocuments: [ + { _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }, + { _id: { $oid: "507f1f77bcf86cd799439012" }, name: "New" }, + { _id: { $oid: "507f1f77bcf86cd799439013" }, name: "Deleted" }, + ], + selectedRowIds: new Set([1, 2, 3]), + }); + + expect(state.canCopyWithExtractor("sql-updates")).toBe(true); + await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(true); + + const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? ""; + expect(copied.match(/\.updateOne\(/g)).toHaveLength(1); + expect(copied).toContain('"name": "Alice"'); + expect(copied).not.toContain('"name": "New"'); + expect(copied).not.toContain('"name": "Deleted"'); + }); + + it("does not expose Mongo SQL Updates without an explicit update 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.canCopyWithExtractor("sql-updates")).toBe(false); + await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(false); + expect(extractDataGridSelection).not.toHaveBeenCalled(); + expect(copyToClipboard).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 9edd45404..355e16ded 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -5,7 +5,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { saveTextFile, sanitizeExportBaseName, compactLocalTimestamp } from "@/lib/export/saveTextFile"; import * as api from "@/lib/backend/api"; import { type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection"; -import type { DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; +import type { DataGridExtractRequest, DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; import { useToast } from "@/composables/useToast"; import { useExportTracker } from "@/composables/useExportTracker"; import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; @@ -469,6 +469,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return targetedRows().filter((item) => !item.isDraft); } + function updateEligibleRows(): RowItem[] { + return targetedRows().filter((item) => !item.isNew && !item.isDraft && !item.isDeleted); + } + function insertableCopyColumnCount(excludePrimaryKeys: boolean, copyColumns = effectiveColumns(sourceColumns.value, columns.value), extractorOptions?: DataGridExtractorOptions): number { const primaryKeySet = new Set((tableMeta.value?.primaryKeys ?? []).map(normalizeColumnName)); return copyColumns.filter((column): column is string => !!column && !isCopyInsertOmittedColumn(databaseType.value, column, tableMeta.value, extractorOptions) && (!excludePrimaryKeys || !primaryKeySet.has(normalizeColumnName(column)))).length; @@ -489,14 +493,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return buildCopyInsertStatement(rowLimit === undefined ? data : { ...data, rows: data.rows.slice(0, rowLimit) }, extractorOptions.sql.excludePrimaryKeysFromInsert, extractorOptions.sql.insertMode); } - async function buildMongoExtractorUpdate(_extractorOptions: DataGridExtractorOptions, rowLimit?: number): Promise { + function mongoUpdateColumnIndexes(request: DataGridExtractRequest): number[] { + const selectedColumns = new Set( + request.selectedColumnIndexes + .map((index) => request.columns[index]?.sourceName ?? request.columns[index]?.displayName) + .filter((column): column is string => !!column) + .map(normalizeColumnName), + ); + return effectiveColumns(sourceColumns.value, columns.value) + .map((column, index) => (column && selectedColumns.has(normalizeColumnName(column)) ? index : -1)) + .filter((index) => index >= 0); + } + + async function buildMongoExtractorUpdate(request: DataGridExtractRequest, rowLimit?: number): Promise { const target = options.mongoUpdateTarget?.value; const documents = options.mongoDocuments?.value; if (!target || !documents) return undefined; - const rows = insertEligibleRows(); + const rows = updateEligibleRows(); if (rows.length === 0) return undefined; const limitedRows = rowLimit === undefined ? rows : rows.slice(0, rowLimit); - const copyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? ""); + const allCopyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? ""); + const selectedColumnIndexes = mongoUpdateColumnIndexes(request); + const copyColumns = selectedColumnIndexes.map((index) => allCopyColumns[index]); await yieldToMainThread(); const statements: string[] = []; for (const item of limitedRows) { @@ -505,7 +523,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) { if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) continue; const source = originalDocument as Record; if (!Object.prototype.hasOwnProperty.call(source, target.idColumn)) continue; - const update = buildMongoCopyUpdateDocument(item.data as MongoInputValue[], copyColumns, item.isDirtyCol, originalDocument, target.idColumn); + const update = buildMongoCopyUpdateDocument( + selectedColumnIndexes.map((index) => item.data[index]) as MongoInputValue[], + copyColumns, + selectedColumnIndexes.map((index) => item.isDirtyCol[index] ?? false), + 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); @@ -513,6 +537,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return statements.length > 0 ? statements.join("\n") : undefined; } + function canBuildMongoExtractorUpdate(request: DataGridExtractRequest): boolean { + const target = options.mongoUpdateTarget?.value; + const documents = options.mongoDocuments?.value; + const rows = updateEligibleRows(); + if (!target || !documents || rows.length === 0) return false; + + const normalizedIdColumn = normalizeColumnName(target.idColumn); + const copyColumns = mongoUpdateColumnIndexes(request).map((index) => effectiveColumns(sourceColumns.value, columns.value)[index] ?? ""); + if (!copyColumns.some((column) => column && normalizeColumnName(column) !== normalizedIdColumn)) return false; + + return rows.every((item) => { + if (item.sourceIndex === undefined) return false; + const document = documents[item.sourceIndex]; + return !!document && typeof document === "object" && !Array.isArray(document) && Object.prototype.hasOwnProperty.call(document, target.idColumn); + }); + } + const { copyWithExtractor, previewWithExtractor, canCopyWithExtractor } = useDataGridExtractor({ columns, displayItems, @@ -537,6 +578,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }, buildMongoInsert: buildMongoExtractorInsert, buildMongoUpdate: buildMongoExtractorUpdate, + canBuildMongoUpdate: canBuildMongoExtractorUpdate, contextCell, contextSelectionIsSynthetic, }); diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 05bd2efe4..85b8c0def 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -47,7 +47,8 @@ interface UseDataGridExtractorOptions { copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) => Promise; canCopySqlInsert: (request: DataGridExtractRequest) => boolean; buildMongoInsert: (extractorOptions: DataGridExtractorOptions, rowLimit?: number) => Promise; - buildMongoUpdate?: (extractorOptions: DataGridExtractorOptions, rowLimit?: number) => Promise; + buildMongoUpdate?: (request: DataGridExtractRequest, rowLimit?: number) => Promise; + canBuildMongoUpdate?: (request: DataGridExtractRequest) => boolean; } export function useDataGridExtractor(options: UseDataGridExtractorOptions) { @@ -183,18 +184,19 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } if (extractor === "sql-updates") { // Mongo has a dedicated updateOne path that doesn't need SQL primary keys. - if (options.databaseType.value === "mongodb") return !!options.buildMongoUpdate; + if (options.databaseType.value === "mongodb") { + const request = buildRequest(extractor, extractorOptions); + return request !== null && (options.canBuildMongoUpdate?.(request) ?? false); + } return canBuildSqlUpdateRequest(); } return selectionData() !== null; } async function resolveMongoExtractorResult(extractor: DataGridCopyExtractorId, request: DataGridExtractRequest, rowLimit?: number) { - if (options.databaseType.value !== "mongodb") return null; - const builder = extractor === "sql-inserts" ? options.buildMongoInsert : extractor === "sql-updates" ? options.buildMongoUpdate : undefined; - if (!builder) return null; - const text = (await builder(request.options, rowLimit)) ?? ""; - if (!text) return null; + if (options.databaseType.value !== "mongodb") return undefined; + if (extractor !== "sql-inserts" && extractor !== "sql-updates") return undefined; + const text = extractor === "sql-inserts" ? ((await options.buildMongoInsert(request.options, rowLimit)) ?? "") : ((await options.buildMongoUpdate?.(request, rowLimit)) ?? ""); return { text, mimeType: "application/javascript", fileExtension: "js", rowCount: rowLimit ?? request.rows.length, columnCount: request.selectedColumnIndexes.length, warnings: undefined, omittedColumns: undefined }; }