From 0f07d337c3efe43e708f409024f21c5308f106a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Thu, 10 Sep 2026 11:59:28 +0800 Subject: [PATCH] feat(mongodb): distinguish missing fields from BSON null in grid --- .../components/document/DocumentBrowser.vue | 9 ++- apps/desktop/src/components/grid/DataGrid.vue | 68 ++++++++++++++-- .../grid/DataGridCellDetailPanel.vue | 6 +- .../components/grid/DataGridDetailDialogs.vue | 8 +- .../useDataGridCellDetailEdit.spec.ts | 62 ++++++++++++++ .../__tests__/useDataGridEditor.spec.ts | 24 +++++- .../__tests__/useDataGridExport.spec.ts | 75 ++++++++++++++++- .../composables/useDataGridCellDetailEdit.ts | 16 +++- .../src/composables/useDataGridEditor.ts | 32 +++++--- .../src/composables/useDataGridExport.ts | 37 +++++++-- .../src/composables/useDataGridExtractor.ts | 11 ++- .../dataGrid/__tests__/dataGridDetail.spec.ts | 80 ++++++++++++++++++- .../src/lib/dataGrid/dataGridDetail.ts | 61 +++++++++++--- .../src/lib/mongo/mongoDocumentValues.ts | 67 +++++++++++++++- packages/app-tests/dataGridDetail.test.ts | 1 + .../app-tests/mongoDocumentValues.test.ts | 54 +++++++++++-- 16 files changed, 542 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/composables/__tests__/useDataGridCellDetailEdit.spec.ts diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 40b1893195..bcfaf0bcbb 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -78,6 +78,7 @@ import { buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentDisplayValue, + mongoDocumentGridValue, mongoDocumentGridColumnTypes, mongoDocumentIdForGrid, parseMongoDocumentInputValue, @@ -414,7 +415,12 @@ function documentGridColumns(documentsToRender: JsonRecord[]): string[] { function documentGridRow(doc: JsonRecord, columns: string[], kind: DocumentStoreKind): QueryResult["rows"][number] { return columns.map((column) => { - const value = mongoDocumentDisplayValue(doc[column]); + const rawValue = doc[column]; + // MongoDB distinguishes a missing field from an explicit BSON null. Keep a + // missing field visually blank; the NULL grid sentinel is reserved for an + // existing field whose BSON value is null. + if (kind === "mongodb" && rawValue === undefined) return ""; + const value = kind === "mongodb" ? mongoDocumentGridValue(rawValue) : mongoDocumentDisplayValue(rawValue); if (value === undefined || value === null) return null; if (column === "_id") return kind === "mongodb" ? mongoDocumentIdForGrid(value) : documentStoreValueForGrid(value, kind); if (typeof value === "object") return documentStoreValueForGrid(value, kind); @@ -2418,6 +2424,7 @@ defineExpose({ focusSearch }); context="results" page-size-preference="table-open" :database-type="props.databaseType" + :mongo-collection-grid="documentStoreProvider.kind === 'mongodb'" :mongo-update-target="mongoUpdateTarget" :editable="documentStoreEditable" :custom-save-handler="customSaveHandler" diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index e72fc160d1..ca38bd1ed0 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -98,6 +98,7 @@ import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructur import { buildTableSelectSql, qualifyTableReferencesInSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; import { uuid } from "@/lib/common/utils"; import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration"; +import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridInputValue } from "@/lib/mongo/mongoDocumentValues"; import { compactHeaderColumnType, formatMetadataColumnTypeLabel, isNumericColumnType, resolveDataGridTypeVisualKind, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType"; import { dataGridCellTextClass, dataGridTypeVisualClass } from "@/lib/dataGrid/dataGridCellTextVisual"; import { DATA_GRID_TYPE_COLOR_KEYS, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme"; @@ -161,7 +162,7 @@ import { type BinaryCellDownloadMode, } from "@/lib/dataGrid/binaryCellDownload"; import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer"; -import { canFormatCellDetailJson, cellDetailEditorText, defaultCellDetailTab, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation"; +import { canFormatCellDetailJson, defaultCellDetailTab, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation"; import { buildDataGridCellDetail, buildDataGridColumnDetail, @@ -516,6 +517,8 @@ interface DataGridProps { manualTransactionSessionId?: string; onManualTransactionMutation?: () => void; mongoUpdateTarget?: MongoCopyUpdateTarget; + /** Enables MongoDB collection-grid presentation for BSON null values. */ + mongoCollectionGrid?: boolean; queryEditabilityReason?: QueryEditabilityReason; allowInsertRows?: boolean; allowDeleteRows?: boolean; @@ -3547,6 +3550,8 @@ const editor = useDataGridEditor({ dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry), confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution), initialEditColumn: firstVisibleColumnIndex, + cellEditorText: cellEditorTextForValue, + normalizeEditorInput: (value) => (props.mongoCollectionGrid ? mongoDocumentGridInputValue(value) : value), getRowItem, pageSize, currentPage, @@ -3823,6 +3828,10 @@ function cellEditContentNeedsExpandedEditor(options: { displayText: string; edit } function cellEditorTextForValue(value: CellValue | undefined, columnIndex: number): string { + if (props.mongoCollectionGrid) { + const documentGridText = mongoDocumentGridEditorText(value); + if (documentGridText !== undefined) return documentGridText; + } return dataGridCellEditorText({ value: value ?? null, databaseType: resolvedDatabaseType.value, @@ -5448,6 +5457,16 @@ const contextCellDetail = computed(() => { if (!cell || cell.col < 0) return null; return cellDetailFor(cell.rowIndex, cell.col); }); +// The MongoDB collection grid stores an internal sentinel for explicit BSON +// null; detail panes must render display text instead of leaking that marker. +function gridDetailRawValue(value: CellValue): string { + return props.mongoCollectionGrid ? (mongoDocumentGridDisplayText(value) ?? displayCellValue(value)) : displayCellValue(value); +} + +function gridDetailIsNullValue(value: CellValue): boolean { + return value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL); +} + function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetail | null { const item = displayItemAt(rowIndex); if (!item) return null; @@ -5461,6 +5480,8 @@ function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetai resultColumnTypes: props.result.column_types, commentByColumn: columnCommentMap.value, displayValue: (value, index) => formatCellCached(value, index), + rawValue: gridDetailRawValue, + isNullValue: gridDetailIsNullValue, isEditable: canEditGridCellDetail({ canEditCell: canEditCellItem(item, columnIndex), isDraft: !!item.isDraft, @@ -5534,6 +5555,8 @@ const rowDetail = computed(() => { resultColumnTypes: props.result.column_types, commentByColumn: columnCommentMap.value, displayValue: (value, index) => formatCellCached(value, index), + rawValue: gridDetailRawValue, + isNullValue: gridDetailIsNullValue, isEditableColumn: (columnIndex) => canEditGridCellDetail({ canEditCell: canEditCellItem(item, columnIndex), @@ -5565,6 +5588,8 @@ const columnDetail = computed(() => { resultColumnTypes: props.result.column_types, commentByColumn: columnCommentMap.value, displayValue: (value, index) => formatCellCached(value, index), + rawValue: gridDetailRawValue, + isNullValue: gridDetailIsNullValue, }); }); @@ -5764,6 +5789,9 @@ const detailEdit = useDataGridCellDetailEdit({ databaseType: resolvedDatabaseType, resultRows: computed(() => props.result.rows), getColumnInfo: (columnIndex) => tableColumnForGridColumn(columnIndex) ?? resultColumnInfoForGridColumn(columnIndex), + cellEditorText: cellEditorTextForValue, + normalizeEditorInput: (value) => (props.mongoCollectionGrid ? mongoDocumentGridInputValue(value) : value), + nullValue: () => (props.mongoCollectionGrid ? MONGO_DOCUMENT_GRID_NULL : null), getRowItem, hydrateLargeValueCell, applyCellValue, @@ -6082,6 +6110,10 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string } function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string { + if (props.mongoCollectionGrid) { + const documentGridText = mongoDocumentGridDisplayText(value); + if (documentGridText !== undefined) return documentGridText; + } const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) { const display = formatForeignKeyCellDisplay(value, columnIndex); @@ -7294,6 +7326,8 @@ const { columnComments: visibleColumnComments, allColumnComments, displayValue: formatCellCached, + cellClipboardText: (value) => (props.mongoCollectionGrid ? mongoDocumentGridEditorText(value) : undefined), + externalCellValue: (value) => (props.mongoCollectionGrid ? mongoDocumentGridExternalValue(value) : value), mongoDocuments: computed(() => props.result.mongo_copy_documents ?? props.result.mongo_documents), spatialColumns: computed(() => props.result.spatial_columns), spatialValues: computed(() => props.result.spatial_values), @@ -8144,7 +8178,7 @@ function applyGeneratedSelectionValue(kind: CellValueGenerationKind, startValue beginBatch(); try { cells.forEach((cell, index) => { - applied = applyVisibleSelectedCellValue(cell.item, cell.visibleCol, values[index] ?? null, allowDraftSelectionValue, { preserveEmptyString: kind === "empty" }) || applied; + applied = applyVisibleSelectedCellValue(cell.item, cell.visibleCol, generatedGridValue(kind, values[index] ?? null), allowDraftSelectionValue, { preserveEmptyString: kind === "empty" }) || applied; }); } finally { commitBatch(); @@ -8159,17 +8193,24 @@ function applyGeneratedSelectionValue(kind: CellValueGenerationKind, startValue function applyGeneratedDetailValue(kind: CellValueGenerationKind, startValue = 1n): boolean { const detail = activeCellDetail.value; if (!detail?.isEditable) return false; - const value = generateCellValues(kind, 1, { startValue })[0] ?? null; + const value = generatedGridValue(kind, generateCellValues(kind, 1, { startValue })[0] ?? null); applyCellValue(detail.rowId, detail.colIndex, value, { preserveEmptyString: kind === "empty", }); - detailEditValue.value = cellDetailEditorText(value); + detailEditValue.value = cellEditorTextForValue(value, detail.colIndex); syncEditorFromDetailEdit(); isEditingDetail.value = activeCellDetailTab.value === "valueEditor"; detailCell.value = { ...detailCell.value! }; return true; } +function generatedGridValue(kind: CellValueGenerationKind, value: string | null): string | null { + // MongoDB collection grids reserve an empty cell for a missing field. Their + // private null marker becomes $set: null in the document save layer. + if (kind === "null" && props.mongoCollectionGrid) return MONGO_DOCUMENT_GRID_NULL; + return value; +} + function openGenerateIncrementDialog(target: "selection" | "detail") { if (target === "selection" && (!props.editable || !selectionHasEditableCells())) return; if (target === "detail" && !activeCellDetail.value?.isEditable) return; @@ -8822,6 +8863,10 @@ async function onGridKeydown(event: KeyboardEvent) { } function detailClipboardText(detail: DataGridCellDetail): string { + if (props.mongoCollectionGrid) { + const documentGridText = mongoDocumentGridEditorText(detail.value); + if (documentGridText !== undefined) return documentGridText; + } if (detail.value === null) return ""; const binaryText = binaryCellClipboardText(detail.value, detail.type, resolvedDatabaseType.value); if (binaryText !== null) return binaryText; @@ -8829,6 +8874,13 @@ function detailClipboardText(detail: DataGridCellDetail): string { return displayCellValue(detail.value); } +// Row/column detail copy payloads must carry external values: the collection +// grid's BSON null marker is restored to a real null instead of leaking the +// internal sentinel into clipboard JSON/TSV. +function gridDetailExternalValue(value: CellValue): CellValue { + return props.mongoCollectionGrid ? mongoDocumentGridExternalValue(value) : value; +} + async function copyDetailValue() { const initialDetail = activeCellDetail.value; if (!initialDetail || !(await hydrateLargeValueCell(initialDetail.rowId, initialDetail.colIndex))) return; @@ -9040,7 +9092,7 @@ async function copyRowDetailJson() { try { const detail = await resolvedRowDetailForCopy(); if (!detail) return; - copyText(dataGridRowDetailJson(detail, undefined, resolvedDatabaseType.value)); + copyText(dataGridRowDetailJson(detail, undefined, resolvedDatabaseType.value, gridDetailExternalValue)); } catch (error) { reportLargeValueLoadError(error); } @@ -9050,7 +9102,7 @@ async function copyRowDetailTsv() { try { const detail = await resolvedRowDetailForCopy(); if (!detail) return; - copyText(dataGridRowDetailTsv(detail, resolvedDatabaseType.value)); + copyText(dataGridRowDetailTsv(detail, resolvedDatabaseType.value, gridDetailExternalValue)); } catch (error) { reportLargeValueLoadError(error); } @@ -9067,7 +9119,7 @@ async function copyColumnDetailJson() { try { const detail = await resolvedColumnDetailForCopy(); if (!detail) return; - copyText(dataGridColumnDetailJson(detail, resolvedDatabaseType.value)); + copyText(dataGridColumnDetailJson(detail, resolvedDatabaseType.value, gridDetailExternalValue)); } catch (error) { reportLargeValueLoadError(error); } @@ -9077,7 +9129,7 @@ async function copyColumnDetailTsv() { try { const detail = await resolvedColumnDetailForCopy(); if (!detail) return; - copyText(dataGridColumnDetailTsv(detail, resolvedDatabaseType.value)); + copyText(dataGridColumnDetailTsv(detail, resolvedDatabaseType.value, gridDetailExternalValue)); } catch (error) { reportLargeValueLoadError(error); } diff --git a/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue b/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue index 02c0bba135..cc6a8d210f 100644 --- a/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue +++ b/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue @@ -97,7 +97,7 @@ defineExpose({ openSearch });
{{ t("grid.nullValue") }}
-
{{ detail.value === null ? "true" : "false" }}
+
{{ (detail.isNull ?? detail.value === null) ? "true" : "false" }}
{{ t("grid.valueLength") }}
@@ -124,7 +124,7 @@ defineExpose({ openSearch });
{{ t("grid.nullValue") }}
-
{{ detail.value === null ? "true" : "false" }}
+
{{ (detail.isNull ?? detail.value === null) ? "true" : "false" }}
{{ t("grid.valueLength") }}
@@ -202,7 +202,7 @@ defineExpose({ openSearch }); >
- {{ t("grid.setNull") }}
diff --git a/apps/desktop/src/components/grid/DataGridDetailDialogs.vue b/apps/desktop/src/components/grid/DataGridDetailDialogs.vue index 3f4a404a2d..8388a82db5 100644 --- a/apps/desktop/src/components/grid/DataGridDetailDialogs.vue +++ b/apps/desktop/src/components/grid/DataGridDetailDialogs.vue @@ -95,11 +95,11 @@ function onColumnDetailKeydown(event: KeyboardEvent) {
{{ field.comment }}
-
{{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: {{ field.value === null ? "true" : field.length }}
+
{{ (field.isNull ?? field.value === null) ? t("grid.nullValue") : t("grid.valueLength") }}: {{ (field.isNull ?? field.value === null) ? "true" : field.length }}
-
{{ field.rawValuePreview }}
+
{{ field.rawValuePreview }}
{{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }}
{{ t("grid.formattedJson") }}
@@ -165,11 +165,11 @@ function onColumnDetailKeydown(event: KeyboardEvent) { {{ field.rowNumber }} -
{{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: {{ field.value === null ? "true" : field.length }}
+
{{ (field.isNull ?? field.value === null) ? t("grid.nullValue") : t("grid.valueLength") }}: {{ (field.isNull ?? field.value === null) ? "true" : field.length }}
-
{{ field.rawValuePreview }}
+
{{ field.rawValuePreview }}
{{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }}
{{ t("grid.formattedJson") }}
diff --git a/apps/desktop/src/composables/__tests__/useDataGridCellDetailEdit.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridCellDetailEdit.spec.ts new file mode 100644 index 0000000000..21dfc1ab96 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridCellDetailEdit.spec.ts @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom + +import { computed, ref } from "vue"; +import { describe, expect, it, vi } from "vitest"; +import { useDataGridCellDetailEdit } from "@/composables/useDataGridCellDetailEdit"; +import { MONGO_DOCUMENT_GRID_NULL } from "@/lib/mongo/mongoDocumentValues"; +import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; + +function detail(value: string): DataGridCellDetail { + return { + rowNumber: 1, + rowId: 3, + colIndex: 1, + column: "value", + type: "", + comment: "", + value, + rawValue: value, + rawValuePreview: value, + displayValue: "NULL", + displayValuePreview: "NULL", + isValuePreviewTruncated: false, + imagePreviewUrl: null, + length: value.length, + formattedJson: "", + isEditable: true, + }; +} + +describe("useDataGridCellDetailEdit", () => { + it("preserves the Mongo collection null marker in detail editing and set-null", async () => { + const activeDetail = ref(detail(MONGO_DOCUMENT_GRID_NULL)); + const applyCellValue = vi.fn(); + const editorText = (value: string | number | boolean | null) => (value === MONGO_DOCUMENT_GRID_NULL ? "NULL" : String(value ?? "")); + const editor = useDataGridCellDetailEdit({ + activeDetail: computed(() => activeDetail.value), + activeTab: ref("details"), + jsonFormatted: computed(() => false), + databaseType: computed(() => "mongodb"), + resultRows: computed(() => [["id", MONGO_DOCUMENT_GRID_NULL]]), + getColumnInfo: () => undefined, + cellEditorText: editorText, + nullValue: () => MONGO_DOCUMENT_GRID_NULL, + getRowItem: () => ({ sourceIndex: 0, isNew: false, isDeleted: false }), + hydrateLargeValueCell: async () => true, + applyCellValue, + restoreCellValue: vi.fn(), + syncEditor: vi.fn(), + refreshDetail: vi.fn(), + warnFormattedJsonEdit: vi.fn(), + }); + + await editor.startDetailEdit(); + expect(editor.detailEditValue.value).toBe("NULL"); + + editor.commitDetailEdit(); + expect(applyCellValue).toHaveBeenLastCalledWith(3, 1, MONGO_DOCUMENT_GRID_NULL); + + editor.setDetailNull(); + expect(applyCellValue).toHaveBeenLastCalledWith(3, 1, MONGO_DOCUMENT_GRID_NULL); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index c82840fd59..e35ad3b3e1 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -2,6 +2,8 @@ import { computed, nextTick, ref, type Ref } from "vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearDataGridPendingSnapshot, DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDataGridEditor } from "@/composables/useDataGridEditor"; +import { clearDataGridClipboardCopy, parseDataGridClipboard, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard"; +import { buildMongoUpdateDocument, MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridInputValue, mongoDocumentGridValue } from "@/lib/mongo/mongoDocumentValues"; import type { CellValue } from "@/lib/dataGrid/cellValue"; const mocks = vi.hoisted(() => ({ @@ -46,6 +48,7 @@ function createEditorWithResult( existingRows: CellValue[][] = [], onCellValueChanged?: (rowId: number, columnIndex: number) => void, tableColumns?: Array<{ name: string; data_type: string; extra?: string; column_default?: string }>, + mongoCollectionGrid = false, ) { let editor: ReturnType; const result = ref<{ columns: string[]; rows: CellValue[][] }>({ @@ -56,7 +59,8 @@ function createEditorWithResult( editor = useDataGridEditor({ result: computed(() => result.value), editable: computed(() => true), - databaseType: computed(() => "postgres"), + databaseType: computed(() => (mongoCollectionGrid ? "mongodb" : "postgres")), + normalizeEditorInput: mongoCollectionGrid ? mongoDocumentGridInputValue : undefined, connectionId: computed(() => "connection-1"), database: computed(() => "app"), tableMeta: computed(() => ({ @@ -1188,3 +1192,21 @@ describe("useDataGridEditor cell edit focus", () => { vi.unstubAllGlobals(); }); }); + +describe("Mongo collection-grid clipboard round-trip", () => { + it.each([null, MONGO_DOCUMENT_GRID_NULL, "\u0000dbx:mongo-document-grid:string:literal", "NULL"])("preserves BSON value %j through paste and save", (bsonValue) => { + const encoded = mongoDocumentGridValue(bsonValue) as string; + const editor = createEditor(undefined, true, undefined, undefined, [["before", "", ""]], undefined, undefined, true); + try { + // The OS clipboard is display text; the internal matrix retains encoding. + rememberDataGridClipboardCopy("copied text", [[encoded]]); + const pasted = parseDataGridClipboard("copied text")[0]![0]!; + editor.applyCellValue(0, 0, pasted); + const changes = editor.dirtyRows.value.get(0)!; + expect(changes.get(0)).toBe(encoded); + expect(buildMongoUpdateDocument(changes, ["value"], { value: "before" })).toEqual({ $set: { value: bsonValue } }); + } finally { + clearDataGridClipboardCopy(); + } + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index eaa0f3dece..4665d6bfd6 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -1,4 +1,5 @@ import { computed, ref } from "vue"; +import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/useDataGridExport"; import type { DatabaseType } from "@/types/database"; @@ -7,9 +8,10 @@ import { copyToClipboard } from "@/lib/common/clipboard"; import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; import type { CellSelectionMatrix, SelectionData } from "@/lib/dataGrid/gridSelection"; import type { CellValue } from "@/lib/dataGrid/cellValue"; -import { extractDataGridSelection, exportQueryResultJson } from "@/lib/backend/api"; +import { extractDataGridSelection, exportQueryResultCsv, exportQueryResultJson } from "@/lib/backend/api"; import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS } from "@/lib/dataGrid/dataGridCopyExtractor"; import { clearDataGridClipboardCopy, parseDataGridClipboard } from "@/lib/dataGrid/dataGridClipboard"; +import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridExternalValue } from "@/lib/mongo/mongoDocumentValues"; const toast = vi.fn(); @@ -46,6 +48,7 @@ vi.mock("@/lib/backend/api", async (importOriginal) => { return { ...original, extractDataGridSelection: vi.fn(), + exportQueryResultCsv: vi.fn(), exportQueryResultJson: vi.fn(), }; }); @@ -72,6 +75,7 @@ function createMongoExportState(options: { contextColumn?: number; syntheticContext?: boolean; fullExportResult?: UseDataGridExportOptions["fullExportResult"]; + externalCellValue?: UseDataGridExportOptions["externalCellValue"]; }) { const items = options.items ?? [options.item]; const selectedRowIds = options.selectedRowIds ?? new Set(); @@ -102,6 +106,7 @@ function createMongoExportState(options: { selectedRowIds: ref(selectedRowIds), hasRowSelection: computed(() => selectedRowIds.size > 0), fullExportResult: options.fullExportResult, + externalCellValue: options.externalCellValue, }; return useDataGridExport(state); } @@ -1513,6 +1518,74 @@ describe("useDataGridExport prepared row statements", () => { expect(copyToClipboard).not.toHaveBeenCalled(); }); + it("restores Mongo collection-grid values before bulk copy, extractors, and CSV export", async () => { + const item = { ...row(["1", MONGO_DOCUMENT_GRID_NULL]), sourceIndex: 0 }; + const state = createMongoExportState({ + columns: ["_id", "nullable"], + item, + mongoDocuments: [{ _id: "1", nullable: null }], + externalCellValue: (value) => mongoDocumentGridExternalValue(value) as CellValue, + selectedCellMatrix: { + rowIndexes: [0], + columnIndexes: [1], + columns: ["nullable"], + rows: [[MONGO_DOCUMENT_GRID_NULL]], + }, + }); + + await state.copyAll(); + expect(copyToClipboard).toHaveBeenCalledWith("_id\tnullable\n1\t"); + + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "", mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 1, columnCount: 1 }); + await expect(state.copyWithExtractor("tsv")).resolves.toBe(true); + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ rows: [[null]] })); + + setActivePinia(createPinia()); + await state.exportCurrentPageCsv(); + expect(exportQueryResultCsv).toHaveBeenCalledWith(expect.any(String), ["_id", "nullable"], [["1", null]], expect.anything()); + + const reservedString = MONGO_DOCUMENT_GRID_NULL; + const fullExportState = createMongoExportState({ + columns: ["_id", "value"], + item: { ...row(["1", mongoDocumentGridExternalValue(reservedString)]), sourceIndex: 0 }, + mongoDocuments: [{ _id: "1", value: reservedString }], + externalCellValue: (value) => mongoDocumentGridExternalValue(value) as CellValue, + fullExportResult: async () => ({ + columns: ["_id", "value"], + column_types: ["", ""], + rows: [["1", reservedString]], + mongo_documents: [{ _id: "1", value: reservedString }], + affected_rows: 1, + execution_time_ms: 1, + }), + }); + + await fullExportState.exportCsv(); + expect(exportQueryResultCsv).toHaveBeenLastCalledWith(expect.any(String), ["_id", "value"], [["1", reservedString]], expect.anything()); + }); + + it("exports missing Mongo fields as null while retaining explicit empty strings", async () => { + const columns = ["_id", "missing", "nullable", "empty"]; + const document = { _id: "1", nullable: null, empty: "" }; + const item = { ...row(["1", "", MONGO_DOCUMENT_GRID_NULL, ""]), sourceIndex: 0 }; + const state = createMongoExportState({ + columns, + item, + mongoDocuments: [document], + externalCellValue: mongoDocumentGridExternalValue, + fullExportResult: async () => ({ + columns, + column_types: ["", "", "", ""], + rows: [item.data], + mongo_copy_documents: [document], + affected_rows: 1, + execution_time_ms: 1, + }), + }); + await state.exportJson(); + expect(exportQueryResultJson).toHaveBeenCalledWith(expect.any(String), columns, [["1", null, null, ""]]); + }); + it("preserves Mongo Extended JSON objects and dates in JSON exports", async () => { const columns = ["_id", "valueMap", "createdTime"]; const mongoCopyDocument = { diff --git a/apps/desktop/src/composables/useDataGridCellDetailEdit.ts b/apps/desktop/src/composables/useDataGridCellDetailEdit.ts index 8606683b88..b22d12b91e 100644 --- a/apps/desktop/src/composables/useDataGridCellDetailEdit.ts +++ b/apps/desktop/src/composables/useDataGridCellDetailEdit.ts @@ -1,5 +1,5 @@ import { computed, ref, watch, type ComputedRef, type Ref } from "vue"; -import { cellDetailEditorText, canFormatCellDetailJson, compactJsonText, formatJsonText, looksLikeJsonContainerText, valueEditorActions, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation"; +import { canFormatCellDetailJson, compactJsonText, formatJsonText, looksLikeJsonContainerText, valueEditorActions, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation"; import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; import { dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion"; import type { CellValue } from "@/lib/dataGrid/cellValue"; @@ -19,6 +19,9 @@ export interface UseDataGridCellDetailEditOptions { databaseType: ComputedRef; resultRows: ComputedRef; getColumnInfo: (columnIndex: number) => ColumnInfo | Pick | undefined; + cellEditorText?: (value: CellValue, columnIndex: number) => string; + normalizeEditorInput?: (value: string, columnIndex: number) => string; + nullValue?: (columnIndex: number) => string | null; getRowItem: (rowId: number) => DetailEditRowItem | undefined; hydrateLargeValueCell: (rowId: number, columnIndex: number) => Promise; applyCellValue: (rowId: number, columnIndex: number, value: string | null) => void; @@ -63,6 +66,7 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti }); function editorText(value: CellValue, columnIndex: number): string { + if (options.cellEditorText) return options.cellEditorText(value, columnIndex); return dataGridCellEditorText({ value, databaseType: options.databaseType.value, @@ -131,7 +135,8 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti isEditingDetail.value = false; const item = options.getRowItem(detail.rowId); if (!item || item.isDeleted) return; - options.applyCellValue(detail.rowId, detail.colIndex, detailEditValue.value); + const value = detailEditValue.value === detailEditOriginalValue.value && (typeof detail.value === "string" || detail.value === null) ? detail.value : (options.normalizeEditorInput?.(detailEditValue.value, detail.colIndex) ?? detailEditValue.value); + options.applyCellValue(detail.rowId, detail.colIndex, value); detailEditOriginalValue.value = detailEditValue.value; allowActiveCellDetailResync = true; options.refreshDetail(); @@ -175,8 +180,11 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti } function setValueEditorNull() { + const detail = options.activeDetail.value; + if (!detail || !detail.isEditable) return; + const value = options.nullValue?.(detail.colIndex) ?? null; setDetailNull(); - detailEditValue.value = cellDetailEditorText(null); + detailEditValue.value = editorText(value, detail.colIndex); detailEditOriginalValue.value = detailEditValue.value; syncEditorFromDetailEdit(); isEditingDetail.value = options.activeTab.value === "valueEditor"; @@ -211,7 +219,7 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti if (!detail || !detail.isEditable) return; const item = options.getRowItem(detail.rowId); if (!item || item.isDeleted) return; - options.applyCellValue(detail.rowId, detail.colIndex, null); + options.applyCellValue(detail.rowId, detail.colIndex, options.nullValue?.(detail.colIndex) ?? null); resetDetailEdit(); options.refreshDetail(); } diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 695ad15f0b..eae5eb1088 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -119,6 +119,10 @@ export interface UseDataGridEditorOptions { dataGridQuickEntryEnabled?: ComputedRef; confirmDangerousRowDeletion?: ComputedRef; initialEditColumn?: ComputedRef; + /** Converts a grid value to the text presented by the cell editor. */ + cellEditorText?: (value: CellValue, columnIndex: number) => string; + /** Normalizes user-entered editor text before cell type coercion. */ + normalizeEditorInput?: (value: string, columnIndex: number) => string; getRowItem: (rowId: number) => RowItem | undefined; pageSize: Ref; currentPage: Ref; @@ -241,6 +245,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { dataGridQuickEntryEnabled = computed(() => false), confirmDangerousRowDeletion = computed(() => true), initialEditColumn, + cellEditorText, + normalizeEditorInput, getRowItem, pageSize, currentPage, @@ -640,7 +646,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { function coerceCellValue(value: string, oldValue: CellValue | undefined, columnIndex: number, options: ApplyCellValueOptions = {}): CellValue { return coerceDataGridCellValue({ - value, + value: normalizeEditorInput?.(value, columnIndex) ?? value, oldValue, databaseType: resolvedDatabaseType.value, columnInfo: tableColumnForGridColumn(columnIndex), @@ -650,11 +656,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function coerceCommittedCellValue(value: string, currentValue: CellValue | undefined, oldValue: CellValue | undefined, columnIndex: number): CellValue { - const editorText = dataGridCellEditorText({ - value: currentValue, - databaseType: resolvedDatabaseType.value, - columnInfo: tableColumnForGridColumn(columnIndex), - }); + const editorText = + cellEditorText?.(currentValue ?? null, columnIndex) ?? + dataGridCellEditorText({ + value: currentValue, + databaseType: resolvedDatabaseType.value, + columnInfo: tableColumnForGridColumn(columnIndex), + }); // Keep the original CellValue when the editor text was not changed. This // avoids turning a displayed value such as number 1 into string "1" when // result and table metadata use different representations. @@ -819,11 +827,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { suppressNextBlurCommit = false; editingCell.value = { rowId, col: colIdx }; const val = item?.data[colIdx] ?? null; - editValue.value = dataGridCellEditorText({ - value: val, - databaseType: resolvedDatabaseType.value, - columnInfo: tableColumnForGridColumn(colIdx), - }); + editValue.value = + cellEditorText?.(val, colIdx) ?? + dataGridCellEditorText({ + value: val, + databaseType: resolvedDatabaseType.value, + columnInfo: tableColumnForGridColumn(colIdx), + }); focusEditInput(selectOnFocus); } diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index d7734a1c34..711240b71b 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -84,6 +84,8 @@ export interface UseDataGridExportOptions { mongoUpdateTarget?: ComputedRef; databaseType: ComputedRef; displayValue?: (value: CellValue, columnIndex: number) => string; + cellClipboardText?: (value: CellValue, columnIndex: number) => string | undefined; + externalCellValue?: (value: CellValue, columnIndex: number) => CellValue; identifierQuote?: ComputedRef; connectionId: ComputedRef; database: ComputedRef; @@ -355,7 +357,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const document = documents[rowIndex]; if (!document || typeof document !== "object" || Array.isArray(document)) return row; const source = document as Record; - return columnsToExport.map((column, columnIndex) => (Object.prototype.hasOwnProperty.call(source, column) ? (source[column] as CellValue) : (row[columnIndex] ?? null))); + return columnsToExport.map((column) => (Object.prototype.hasOwnProperty.call(source, column) ? (source[column] as CellValue) : null)); }); } @@ -366,6 +368,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } + function externalizeRows(rows: CellValue[][]): CellValue[][] { + if (!options.externalCellValue) return rows; + return rows.map((row) => row.map((value, columnIndex) => options.externalCellValue!(value, columnIndex))); + } + async function resultToExport( rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, @@ -386,7 +393,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) { if (result) { const columnComments = buildXlsxHeaderOverrides(result.columns, commentsForExportColumns(result.columns), headerMode); return { - ...applyGlobalDateTimeExportFormat({ columns: result.columns, columnTypes: result.column_types ?? [], rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(result.columns, result.rows, result.mongo_copy_documents) : result.rows }, formatDateTime && !preserveMongoExtendedJson), + ...applyGlobalDateTimeExportFormat( + // fullExportResult returns source rows, not the collection grid's + // marker-encoded rows. Applying externalCellValue here would + // mistake a real BSON string in the reserved namespace for a grid + // marker and corrupt the exported value. + { columns: result.columns, columnTypes: result.column_types ?? [], rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(result.columns, result.rows, result.mongo_copy_documents) : result.rows }, + formatDateTime && !preserveMongoExtendedJson, + ), columnComments, spatialColumns: result.spatial_columns, spatialValues: result.spatial_values, @@ -403,7 +417,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const columnComments = buildXlsxHeaderOverrides(normalized.columns, normalized.columnComments, headerMode); return { ...applyGlobalDateTimeExportFormat( - { columns: normalized.columns, columnTypes: normalized.columnTypes, rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(normalized.columns, normalized.rows, normalized.mongoCopyDocuments) : normalized.rows }, + { columns: normalized.columns, columnTypes: normalized.columnTypes, rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(normalized.columns, normalized.rows, normalized.mongoCopyDocuments) : externalizeRows(normalized.rows) }, formatDateTime && !preserveMongoExtendedJson, ), columnComments, @@ -421,7 +435,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { { columns: columns.value, columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""), - rows: preserveMongoExtendedJson && databaseType.value === "mongodb" ? mongoLocalRowsForJson(exportItems) : exportItems.map((item) => item.data), + rows: preserveMongoExtendedJson && databaseType.value === "mongodb" ? mongoLocalRowsForJson(exportItems) : externalizeRows(exportItems.map((item) => item.data)), }, formatDateTime && !preserveMongoExtendedJson, ), @@ -537,6 +551,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return binaryCellClipboardText(value, columnTypes.value?.[columnIndex], databaseType.value) ?? value; } + function externalCellValue(value: CellValue, columnIndex: number): CellValue { + const externalValue = options.externalCellValue?.(value, columnIndex); + return externalValue === undefined ? value : externalValue; + } + function rowToJsonObject(item: RowItem): Record { if (options.databaseType.value === "mongodb" && item.sourceIndex !== undefined) { const original = options.mongoDocuments?.value?.[item.sourceIndex]; @@ -545,7 +564,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } const obj: Record = {}; columns.value.forEach((col, i) => { - const value = binaryClipboardCellValue(item.data[i], i); + const value = externalCellValue(binaryClipboardCellValue(item.data[i], i), i); if (typeof value === "string" && columnTypes.value?.[i]?.trim().toLowerCase() === "json") { try { obj[col] = JSON.parse(value); @@ -577,7 +596,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const [resolvedItem] = await resolveVisibleRowValues([item], [sourceIndex]); const val = resolvedItem?.data[contextCell.value.col] ?? null; // 外部剪贴板呈现文本型 MySQL VARBINARY(NULL 也按空串输出);内部网格副本仍保留原 hex,保证回粘无损。 - const rawValue = clipboardCellValue(binaryClipboardCellValue(val, contextCell.value.col)); + const rawValue = options.cellClipboardText?.(val, sourceIndex) ?? clipboardCellValue(binaryClipboardCellValue(val, contextCell.value.col)); const copyValue = options.databaseType.value === "oracle" && isTemporalColumnType(options.columnTypes.value?.[contextCell.value.col]) ? (options.displayValue?.(val, sourceIndex) ?? rawValue) : rawValue; await copyText(copyValue, { rows: [[val]] }); } @@ -716,6 +735,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) { buildMongoInsert: buildMongoExtractorInsert, buildMongoUpdate: buildMongoExtractorUpdate, canBuildMongoUpdate: canBuildMongoExtractorUpdate, + externalCellValue: (value, columnIndex) => { + const externalValue = options.externalCellValue?.(value as CellValue, columnIndex); + return externalValue === undefined ? value : externalValue; + }, contextCell, contextSelectionIsSynthetic, }); @@ -723,7 +746,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function copyAll() { const rows = (await resolveVisibleRowValues(displayItems.value.filter((item) => !item.isDraft))).map((item) => item.data); // formatTsv 引用处理解码后文本中可能出现的制表符/换行;内部副本仍用原始 rows(hex)保证回粘无损。 - const decodedRows = rows.map((row) => row.map((cell, index) => binaryClipboardCellValue(cell, index))); + const decodedRows = rows.map((row) => row.map((cell, index) => externalCellValue(binaryClipboardCellValue(cell, index), index))); await copyText(formatTsv(columns.value, decodedRows), { rows, header: columns.value }); } diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 4763985f93..7f126e1f15 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -60,6 +60,7 @@ interface UseDataGridExtractorOptions { contextCell: ComputedRef<{ rowId: number; rowIndex: number; col: number } | null> | Ref<{ rowId: number; rowIndex: number; col: number } | null>; contextSelectionIsSynthetic: ComputedRef | Ref; copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) => Promise; + externalCellValue?: (value: unknown, columnIndex: number) => unknown; canCopySqlInsert: (request: DataGridExtractRequest) => boolean; buildMongoInsert: (extractorOptions: DataGridExtractorOptions, rowLimit?: number) => Promise; buildMongoUpdate?: (request: DataGridExtractRequest, rowLimit?: number) => Promise; @@ -83,12 +84,14 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { // 除 SQL 外的剪贴板展示格式都可把文本型 MySQL VARBINARY 从 `0x` 还原为原始字符串。 // SQL 必须继续持有 hex 才能保证 round-trip;rawRows 则始终保存原值,供 DBX 内部网格回粘使用。 - function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, presentBinaryText: boolean): unknown { + function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, presentBinaryText: boolean, columnIndex: number): unknown { if (presentBinaryText) { const text = binaryCellClipboardText(value, columnType, options.databaseType.value); if (text !== null) return text; } - return normalizeValues ? normalizeCellValue(value, columnType) : value; + const normalized = normalizeValues ? normalizeCellValue(value, columnType) : value; + const externalValue = options.externalCellValue?.(normalized, columnIndex); + return externalValue === undefined ? normalized : externalValue; } function selectionData(): SelectionData | null { @@ -210,7 +213,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const normalizeValues = descriptor.category === "json" || descriptor.category === "sql"; const presentBinaryText = descriptor.category !== "sql"; const rawRows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => row[sourceIndex])); - const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, presentBinaryText))); + const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, presentBinaryText, index))); const tableMeta = descriptor.category === "sql" ? compactTableMeta( @@ -269,7 +272,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { return sourceIndex !== undefined && values.has(sourceIndex) ? values.get(sourceIndex) : value; }); }); - const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.presentBinaryText))); + const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.presentBinaryText, index))); const resolvedRequest = { ...request, rows }; requestSources.set(resolvedRequest, { ...limitedSource, rawRows }); return resolvedRequest; diff --git a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts index bbd87f8b66..35b7a08fed 100644 --- a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts +++ b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts @@ -1,5 +1,7 @@ +import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridDisplayText, mongoDocumentGridExternalValue, mongoDocumentGridValue } from "@/lib/mongo/mongoDocumentValues"; +import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import { describe, expect, it } from "vitest"; -import { buildDeleteRowConfirmDetails, dataGridColumnDetailTsv, dataGridRowDetailTsv, type DataGridColumnDetail, type DataGridRowDetail } from "../dataGridDetail"; +import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, buildDeleteRowConfirmDetails, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridColumnDetail, type DataGridRowDetail } from "../dataGridDetail"; type TestRow = string[]; @@ -142,3 +144,79 @@ describe("data grid detail TSV", () => { expect(dataGridColumnDetailTsv(columnDetail)).toBe("\nNULL"); }); }); + +describe("Mongo collection cell detail presentation", () => { + it.each([null, "NULL", MONGO_DOCUMENT_GRID_NULL])("presents BSON value %j without leaking grid encoding", (bsonValue) => { + const value = mongoDocumentGridValue(bsonValue) as string; + const text = mongoDocumentGridDisplayText(value) ?? displayCellValue(value); + const detail = buildDataGridCellDetail({ + rowIndex: 0, + rowId: 0, + row: [value], + columns: ["value"], + columnIndex: 0, + displayValue: () => text, + rawValue: () => text, + isNullValue: (cell) => cell === MONGO_DOCUMENT_GRID_NULL, + isEditable: true, + }); + expect(detail).toMatchObject({ value, rawValue: text, rawValuePreview: text, isNull: bsonValue === null, length: bsonValue === null ? 0 : text.length }); + expect(detail!.rawValue).not.toContain("\u0000"); + }); + + it("presents the BSON null marker as NULL text in row and column detail fields", () => { + const value = mongoDocumentGridValue(null) as string; + const rawValue = (cell: CellValue) => mongoDocumentGridDisplayText(cell) ?? displayCellValue(cell); + const isNullValue = (cell: CellValue) => cell === MONGO_DOCUMENT_GRID_NULL; + const rowDetail = buildDataGridRowDetail({ + rowIndex: 0, + rowId: 1, + row: [value], + columns: ["value"], + columnIndexes: [0], + displayValue: rawValue, + rawValue, + isNullValue, + }); + const columnDetail = buildDataGridColumnDetail({ + rows: [{ rowIndex: 0, rowId: 1, row: [value] }], + columns: ["value"], + columnIndex: 0, + displayValue: rawValue, + rawValue, + isNullValue, + }); + + for (const field of [...rowDetail.fields, ...columnDetail!.fields]) { + expect(field).toMatchObject({ value, rawValue: "NULL", rawValuePreview: "NULL", isNull: true, length: 0 }); + expect(field.rawValuePreview).not.toContain("\u0000"); + } + }); + + it("restores external BSON null values in row and column detail copy payloads", () => { + const value = mongoDocumentGridValue(null) as string; + const rowDetail = buildDataGridRowDetail({ + rowIndex: 0, + rowId: 1, + row: [value], + columns: ["value"], + columnIndexes: [0], + displayValue: (cell) => displayCellValue(cell), + }); + const columnDetail = buildDataGridColumnDetail({ + rows: [{ rowIndex: 0, rowId: 1, row: [value] }], + columns: ["value"], + columnIndex: 0, + displayValue: (cell) => displayCellValue(cell), + }); + + const rowJson = dataGridRowDetailJson(rowDetail, undefined, undefined, mongoDocumentGridExternalValue); + const columnJson = dataGridColumnDetailJson(columnDetail!, undefined, mongoDocumentGridExternalValue); + expect(rowJson).toBe('{\n "value": null\n}'); + expect(columnJson).toBe('[\n {\n "row": 1,\n "value": null\n }\n]'); + expect(rowJson).not.toContain("\u0000"); + expect(columnJson).not.toContain("\u0000"); + expect(dataGridRowDetailTsv(rowDetail, undefined, mongoDocumentGridExternalValue)).toBe(""); + expect(dataGridColumnDetailTsv(columnDetail!, undefined, mongoDocumentGridExternalValue)).toBe(""); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts index c1c2fe4e70..051bcc3b99 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts @@ -21,6 +21,7 @@ export interface DataGridCellDetail { isValuePreviewTruncated: boolean; imagePreviewUrl: string | null; length: number; + isNull?: boolean; formattedJson: string; isEditable: boolean; } @@ -50,6 +51,8 @@ export interface BuildDataGridCellDetailOptions { commentByColumn?: ReadonlyMap; displayValue: (value: CellValue, columnIndex: number) => string; isEditable: boolean; + rawValue?: (value: CellValue, columnIndex: number) => string; + isNullValue?: (value: CellValue) => boolean; databaseType?: DatabaseType; includeBinaryImagePreview?: boolean; isValuePreviewTruncated?: boolean; @@ -65,6 +68,8 @@ export interface BuildDataGridRowDetailOptions { resultColumnTypes?: readonly string[]; commentByColumn?: ReadonlyMap; displayValue: (value: CellValue, columnIndex: number) => string; + rawValue?: (value: CellValue, columnIndex: number) => string; + isNullValue?: (value: CellValue) => boolean; isEditableColumn?: (columnIndex: number) => boolean; isValuePreviewTruncated?: (columnIndex: number) => boolean; } @@ -85,6 +90,8 @@ export interface BuildDataGridColumnDetailOptions { resultColumnTypes?: readonly string[]; commentByColumn?: ReadonlyMap; displayValue: (value: CellValue, columnIndex: number) => string; + rawValue?: (value: CellValue, columnIndex: number) => string; + isNullValue?: (value: CellValue) => boolean; } export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions): DataGridCellDetail | null { @@ -92,7 +99,8 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions) if (column === undefined) return null; const value = options.row[options.columnIndex] ?? null; - const rawValue = displayCellValue(value); + const rawValue = options.rawValue?.(value, options.columnIndex) ?? displayCellValue(value); + const isNull = options.isNullValue?.(value) ?? value === null; const displayValue = options.displayValue(value, options.columnIndex); const formattedJson = typeof value === "string" && looksLikeJsonContainer(value) ? (formatJsonText(value) ?? "") : ""; const rawValuePreview = previewText(rawValue); @@ -116,7 +124,8 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions) binary: options.includeBinaryImagePreview !== false, databaseType: options.databaseType, }), - length: value === null ? 0 : String(value).length, + length: isNull ? 0 : rawValue.length, + isNull, formattedJson, isEditable: options.isEditable, }; @@ -138,6 +147,8 @@ export function buildDataGridColumnDetail(options: BuildDataGridColumnDetailOpti resultColumnTypes: options.resultColumnTypes, commentByColumn: options.commentByColumn, displayValue: options.displayValue, + rawValue: options.rawValue, + isNullValue: options.isNullValue, isEditable: row.isEditable ?? false, includeBinaryImagePreview: false, isValuePreviewTruncated: row.isValuePreviewTruncated, @@ -167,6 +178,8 @@ export function buildDataGridRowDetail(options: BuildDataGridRowDetailOptions): resultColumnTypes: options.resultColumnTypes, commentByColumn: options.commentByColumn, displayValue: options.displayValue, + rawValue: options.rawValue, + isNullValue: options.isNullValue, isEditable: options.isEditableColumn?.(columnIndex) ?? false, includeBinaryImagePreview: false, isValuePreviewTruncated: options.isValuePreviewTruncated?.(columnIndex), @@ -187,11 +200,20 @@ function detailColumnType(typeByColumn: ReadonlyMap | undefined, return resultColumnTypes?.[columnIndex]?.trim() ?? ""; } -export function dataGridRowDetailJson(detail: DataGridRowDetail, originalDocument?: unknown, databaseType?: DatabaseType): string { +/** Restores a grid-internal marker value (e.g. the Mongo BSON null sentinel) before it leaves the grid. */ +export type DataGridDetailExternalValue = (value: CellValue) => CellValue; + +function externalDetailFieldValue(value: CellValue, externalValue: DataGridDetailExternalValue | undefined): CellValue { + const external = externalValue?.(value); + return external === undefined ? value : external; +} + +export function dataGridRowDetailJson(detail: DataGridRowDetail, originalDocument?: unknown, databaseType?: DatabaseType, externalValue?: DataGridDetailExternalValue): string { if (originalDocument !== undefined) return JSON.stringify(jsonDetailDisplayValue(originalDocument), null, 2); const row: Record = {}; detail.fields.forEach((field) => { - row[field.column] = binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value; + const value = externalDetailFieldValue(field.value, externalValue); + row[field.column] = binaryCellClipboardText(value, field.type, databaseType) ?? value; }); return JSON.stringify(jsonDetailDisplayValue(row), null, 2); } @@ -215,23 +237,36 @@ export function jsonDetailDisplayValue(value: unknown): unknown { return value; } -export function dataGridRowDetailTsv(detail: DataGridRowDetail, databaseType?: DatabaseType): string { - return detail.fields.map((field) => clipboardCellValue(binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value)).join("\t"); +export function dataGridRowDetailTsv(detail: DataGridRowDetail, databaseType?: DatabaseType, externalValue?: DataGridDetailExternalValue): string { + return detail.fields + .map((field) => { + const value = externalDetailFieldValue(field.value, externalValue); + return clipboardCellValue(binaryCellClipboardText(value, field.type, databaseType) ?? value); + }) + .join("\t"); } -export function dataGridColumnDetailJson(detail: DataGridColumnDetail, databaseType?: DatabaseType): string { +export function dataGridColumnDetailJson(detail: DataGridColumnDetail, databaseType?: DatabaseType, externalValue?: DataGridDetailExternalValue): string { return JSON.stringify( - detail.fields.map((field) => ({ - row: field.rowNumber, - value: binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value, - })), + detail.fields.map((field) => { + const value = externalDetailFieldValue(field.value, externalValue); + return { + row: field.rowNumber, + value: binaryCellClipboardText(value, field.type, databaseType) ?? value, + }; + }), null, 2, ); } -export function dataGridColumnDetailTsv(detail: DataGridColumnDetail, databaseType?: DatabaseType): string { - return detail.fields.map((field) => clipboardCellValue(binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value)).join("\n"); +export function dataGridColumnDetailTsv(detail: DataGridColumnDetail, databaseType?: DatabaseType, externalValue?: DataGridDetailExternalValue): string { + return detail.fields + .map((field) => { + const value = externalDetailFieldValue(field.value, externalValue); + return clipboardCellValue(binaryCellClipboardText(value, field.type, databaseType) ?? value); + }) + .join("\n"); } export interface BuildDeleteRowConfirmDetailsOptions { diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index a71963fd36..540624c5a3 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -1,9 +1,17 @@ +import type { CellValue } from "@/lib/dataGrid/cellValue"; + export type MongoInputValue = string | number | boolean | null; const MONGO_SHELL_DATE_PATTERN = /^(?:ISODate|new Date)\(\s*(["'])(.+)\1\s*\)$/; const MONGO_SHELL_NUMBER_LONG_PATTERN = /^NumberLong\(\s*(["'])(-?\d+)\1\s*\)$/; const MONGO_OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/; const MONGO_INTEGER_PATTERN = /^-?\d+$/; +// These values are internal to the MongoDB collection grid. BSON strings may +// contain any UTF-8 text, so strings in this reserved namespace are escaped +// before entering the grid and restored before being saved. +const MONGO_DOCUMENT_GRID_PREFIX = "\u0000dbx:mongo-document-grid:"; +const MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX = `${MONGO_DOCUMENT_GRID_PREFIX}string:`; +export const MONGO_DOCUMENT_GRID_NULL = `${MONGO_DOCUMENT_GRID_PREFIX}null`; const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); const MIN_BSON_INT64 = -9223372036854775808n; const MAX_BSON_INT64 = 9223372036854775807n; @@ -81,7 +89,6 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown { } export function mongoDocumentDisplayValue(value: unknown): unknown { - if (value === null) return "NULL"; if (value && typeof value === "object" && !Array.isArray(value)) { const object = value as Record; if (Object.keys(object).length === 1 && typeof object.$numberLong === "string") return `NumberLong(${JSON.stringify(object.$numberLong)})`; @@ -89,9 +96,59 @@ export function mongoDocumentDisplayValue(value: unknown): unknown { return value; } +/** + * Maps BSON values into the flat collection-grid representation. The grid + * needs an internal sentinel for an explicit BSON null because an empty cell + * represents a field that does not exist. The grid formatter renders that + * sentinel as NULL, while a literal "NULL" remains ordinary string data. + */ +export function mongoDocumentGridValue(value: unknown): unknown { + if (value === null) return MONGO_DOCUMENT_GRID_NULL; + if (typeof value === "string" && value.startsWith(MONGO_DOCUMENT_GRID_PREFIX)) return `${MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX}${value}`; + return mongoDocumentDisplayValue(value); +} + +function mongoDocumentGridEscapedString(value: unknown): string | undefined { + return typeof value === "string" && value.startsWith(MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX) ? value.slice(MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX.length) : undefined; +} + +/** Returns the text presented in a collection-grid editor, when customized. */ +export function mongoDocumentGridEditorText(value: unknown): string | undefined { + if (value === MONGO_DOCUMENT_GRID_NULL) return "NULL"; + return mongoDocumentGridEscapedString(value); +} + +/** Returns the custom display text required by collection-grid BSON values. */ +export function mongoDocumentGridDisplayText(value: unknown): string | undefined { + if (value === MONGO_DOCUMENT_GRID_NULL) return "NULL"; + const escapedString = mongoDocumentGridEscapedString(value); + if (escapedString !== undefined) return JSON.stringify(escapedString); + return value === "NULL" ? JSON.stringify(value) : undefined; +} + +/** Restores a collection-grid value before it leaves the grid externally. */ +export function mongoDocumentGridExternalValue(value: CellValue): CellValue { + if (value === MONGO_DOCUMENT_GRID_NULL) return null; + const escapedString = mongoDocumentGridEscapedString(value); + return escapedString === undefined ? value : escapedString; +} + +/** Preserves encoded grid clipboard values and escapes other reserved input. */ +export function mongoDocumentGridInputValue(value: string): string { + // Internal copy/paste already carries encoded values; escaping again would + // save BSON null as a literal sentinel string. + if (value === MONGO_DOCUMENT_GRID_NULL || value.startsWith(MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX)) return value; + return value.startsWith(MONGO_DOCUMENT_GRID_PREFIX) ? `${MONGO_DOCUMENT_GRID_ESCAPED_STRING_PREFIX}${value}` : value; +} + function parseMongoExistingFieldInputValue(raw: Exclude, originalValue: unknown): unknown { // Objects and arrays are serialized into grid text too, so the raw document // is the only reliable way to distinguish them from JSON-shaped BSON strings. + // The collection grid's private sentinel represents an explicit BSON null. + // A literal "NULL" remains a normal string, including in Mongo query results. + if (raw === MONGO_DOCUMENT_GRID_NULL) return null; + const escapedString = mongoDocumentGridEscapedString(raw); + if (escapedString !== undefined) return escapedString; if (typeof originalValue === "string") { return typeof raw === "string" ? raw : String(raw); } @@ -202,7 +259,7 @@ export function buildMongoInsertDocument(row: MongoInputValue[], columns: string if (!col || col === "_id") continue; const val = row[ci]; if (val === null) continue; - doc[col] = parseMongoDocumentInputValue(val); + doc[col] = val === MONGO_DOCUMENT_GRID_NULL ? null : (mongoDocumentGridEscapedString(val) ?? parseMongoDocumentInputValue(val)); } return doc; } @@ -218,7 +275,7 @@ export function buildMongoCopyInsertDocument(row: MongoInputValue[], columns: st doc[col] = { $oid: val }; continue; } - doc[col] = parseMongoDocumentInputValue(val); + doc[col] = val === MONGO_DOCUMENT_GRID_NULL ? null : (mongoDocumentGridEscapedString(val) ?? parseMongoDocumentInputValue(val)); } return doc; } @@ -235,7 +292,9 @@ export function buildMongoCopyDocumentFromOriginal(original: unknown, row: Mongo // 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); + if (value !== null) { + document[column] = value === MONGO_DOCUMENT_GRID_NULL ? null : (mongoDocumentGridEscapedString(value) ?? parseMongoDocumentInputValue(value)); + } continue; } if (Object.prototype.hasOwnProperty.call(source, column)) document[column] = source[column]; diff --git a/packages/app-tests/dataGridDetail.test.ts b/packages/app-tests/dataGridDetail.test.ts index 907b06c76d..63df7524e9 100644 --- a/packages/app-tests/dataGridDetail.test.ts +++ b/packages/app-tests/dataGridDetail.test.ts @@ -48,6 +48,7 @@ test("buildDataGridCellDetail preserves full value metadata", () => { isValuePreviewTruncated: false, imagePreviewUrl: null, length: 25, + isNull: false, formattedJson: '{\n "ok": true,\n "items": [\n 1,\n 2\n ]\n}', isEditable: true, }); diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index f0692641bf..c29728fa89 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -8,7 +8,13 @@ import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, + MONGO_DOCUMENT_GRID_NULL, mongoDocumentDisplayValue, + mongoDocumentGridDisplayText, + mongoDocumentGridEditorText, + mongoDocumentGridExternalValue, + mongoDocumentGridInputValue, + mongoDocumentGridValue, mongoDocumentGridColumnTypes, mongoDocumentIdForGrid, parseMongoDocumentInputValue, @@ -40,10 +46,7 @@ test("infers only consistently numeric Mongo grid columns", () => { }, ]; - assert.deepEqual( - mongoDocumentGridColumnTypes(documents, ["native", "int32", "int64", "double", "decimal", "mixedNumeric", "numericString", "mixed", "empty", "missing"]), - ["number", "int32", "int64", "double", "decimal128", "number", "", "", "", ""], - ); + assert.deepEqual(mongoDocumentGridColumnTypes(documents, ["native", "int32", "int64", "double", "decimal", "mixedNumeric", "numericString", "mixed", "empty", "missing"]), ["number", "int32", "int64", "double", "decimal128", "number", "", "", "", ""]); }); test("parses Mongo shell ISODate literals as extended JSON dates", () => { @@ -329,7 +332,9 @@ test("formats other extended JSON values through EJSON.deserialize", () => { }); test("keeps normal Mongo values readable and unsafe Int64 editable", () => { - assert.equal(mongoDocumentDisplayValue(null), "NULL"); + assert.equal(mongoDocumentDisplayValue(null), null); + assert.equal(mongoDocumentGridValue(null), MONGO_DOCUMENT_GRID_NULL); + assert.equal(mongoDocumentGridValue("NULL"), "NULL"); assert.equal(mongoDocumentDisplayValue(undefined), undefined); assert.equal(mongoDocumentDisplayValue(42), 42); assert.equal(mongoDocumentDisplayValue(3.5), 3.5); @@ -353,17 +358,52 @@ test("builds edits for Int32, Double, Date, and unsafe Int64", () => { test("keeps explicit Mongo null fields distinct from missing fields during grid edits", () => { const columns = ["_id", "nullable", "missing"]; - assert.deepEqual(buildMongoUpdateDocument(new Map([[1, "NULL"]]), columns, { _id: "1", nullable: null }), { + assert.deepEqual(buildMongoUpdateDocument(new Map([[1, MONGO_DOCUMENT_GRID_NULL]]), columns, { _id: "1", nullable: null }), { $set: { nullable: null }, }); assert.deepEqual(buildMongoUpdateDocument(new Map([[1, "NULL"]]), columns, { _id: "1", nullable: "NULL" }), { $set: { nullable: "NULL" }, }); + assert.deepEqual(buildMongoUpdateDocument(new Map([[1, "NULL"]]), columns, { _id: "1", nullable: null }), { + $set: { nullable: null }, + }); assert.deepEqual(buildMongoUpdateDocument(new Map([[2, null]]), columns, { _id: "1", nullable: null }), { $unset: { missing: "" }, }); - assert.deepEqual(applyMongoGridChangesToDocument({ _id: "1", nullable: null }, new Map([[1, "NULL"]]), columns), { + assert.deepEqual(applyMongoGridChangesToDocument({ _id: "1", nullable: null }, new Map([[1, MONGO_DOCUMENT_GRID_NULL]]), columns), { + _id: "1", + nullable: null, + }); + assert.deepEqual(buildMongoInsertDocument(["1", MONGO_DOCUMENT_GRID_NULL, null], columns), { + nullable: null, + }); + assert.deepEqual(buildMongoCopyInsertDocument(["1", MONGO_DOCUMENT_GRID_NULL, null], columns), { + _id: 1, + nullable: null, + }); + assert.deepEqual(buildMongoCopyDocumentFromOriginal({ _id: "1", nullable: "before" }, ["1", MONGO_DOCUMENT_GRID_NULL], columns, [false, true, false]), { _id: "1", nullable: null, }); }); + +test("escapes Mongo collection-grid values reserved for BSON null state", () => { + const columns = ["_id", "value"]; + const reservedString = MONGO_DOCUMENT_GRID_NULL; + const gridValue = mongoDocumentGridValue(reservedString); + + assert.notEqual(gridValue, reservedString); + assert.equal(mongoDocumentGridEditorText(gridValue), reservedString); + assert.equal(mongoDocumentGridDisplayText(gridValue), JSON.stringify(reservedString)); + assert.equal(mongoDocumentGridExternalValue(gridValue), reservedString); + assert.equal(mongoDocumentGridInputValue(reservedString), reservedString); + assert.equal(mongoDocumentGridInputValue(gridValue as string), gridValue); + assert.deepEqual(buildMongoUpdateDocument(new Map([[1, gridValue as string]]), columns, { _id: "1", value: reservedString }), { + $set: { value: reservedString }, + }); +}); + +test("restores internal Mongo collection-grid values for external output", () => { + assert.equal(mongoDocumentGridExternalValue(MONGO_DOCUMENT_GRID_NULL), null); + assert.equal(mongoDocumentGridExternalValue("NULL"), "NULL"); +});