diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index a0c523e53e..afc42b07f0 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -13756,7 +13756,14 @@ function openGridSnapshot() { return type ? shortTypeName(compactHeaderColumnType(type)) : undefined; }), columnDetails: columnIndexes.map((index) => visibleColumnComments.value[index]), - rows: selection.rows.map((row) => row.slice()), + rows: selection.rows.map((row) => + row.map((val, cellIdx) => { + if (val === null || val === undefined) return null; + const visibleColIdx = columnIndexes[cellIdx]; + const actualColIdx = visibleColIdx !== undefined ? visibleColumnIndexes.value[visibleColIdx] : undefined; + return formatCellCached(val, actualColIdx); + }), + ), title: props.exportFileBaseName || props.result.sourceLabel || undefined, }; gridSnapshotOpen.value = true; diff --git a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts index d25db2664c..9379fc5e9a 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts @@ -4,7 +4,8 @@ import { createApp, defineComponent, h, markRaw, nextTick, shallowRef, type App, import { createPinia, setActivePinia } from "pinia"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; -import type { QueryResult } from "@/types/database"; +import type { DatabaseType, QueryResult } from "@/types/database"; +import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; import { TooltipProvider } from "@/components/ui/tooltip"; const mocks = vi.hoisted(() => ({ @@ -137,7 +138,7 @@ function pasteGridCell(host: HTMLElement, value: string) { gridRoot.dispatchEvent(paste); } -function mountGrid(initialResult = largeValueResult(), onReload?: () => void) { +function mountGrid(initialResult = largeValueResult(), onReload?: () => void, databaseType: DatabaseType = "mysql", tableMeta?: DataGridTableMeta) { const result = shallowRef(markRaw(initialResult)); const onExecuteSql = vi.fn().mockResolvedValue(undefined); const pinia = createPinia(); @@ -157,12 +158,12 @@ function mountGrid(initialResult = largeValueResult(), onReload?: () => void) { default: () => h(DataGrid, { result: result.value, - databaseType: "mysql", + databaseType, connectionId: "connection-1", database: "dbx", context: "table-data", editable: true, - tableMeta: { + tableMeta: tableMeta ?? { tableName: "events", columns: [ { name: "id", data_type: "int" }, @@ -348,6 +349,36 @@ describe("DataGrid context menu target lifecycle", () => { expect(document.body.textContent).toContain("Grid Snapshot"); }); + it("formats Oracle date cells to display format in grid snapshot", async () => { + const oracleResult: QueryResult = { + columns: ["id", "created_at"], + column_types: ["number", "DATE"], + rows: [[1, "2026-10-09T10:56:57"]], + affected_rows: 0, + execution_time_ms: 0, + }; + const oracleTableMeta: DataGridTableMeta = { + tableName: "events", + columns: [ + { name: "id", data_type: "number" }, + { name: "created_at", data_type: "DATE" }, + ], + primaryKeys: ["id"], + }; + const { host } = mountGrid(oracleResult, undefined, "oracle", oracleTableMeta); + await settle(); + + openContextMenu(rowNumber(host)); + await settle(); + + expect(contextMenuLabels()).toContain("Screenshot selected rows"); + contextMenuButton("Screenshot selected rows").click(); + await settle(); + expect(document.body.textContent).toContain("Grid Snapshot"); + expect(document.body.textContent).toContain("2026-10-09 10:56:57"); + expect(document.body.textContent).not.toContain("2026-10-09T10:56:57"); + }); + it.each(["WHERE", "ORDER BY"] as const)("keeps the native context menu for the %s condition editor", async (placeholder) => { const { host } = mountGrid(hydratedResult(1, "value")); await settle(); diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 572ecb3693..58ac1e3256 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -1001,6 +1001,121 @@ describe("useDataGridExport prepared row statements", () => { expect(copyToClipboard).toHaveBeenCalledWith("2020-12-02 15:18:29"); }); + it("copies Oracle temporal cells using the displayed value via copyWithPreference", async () => { + const table: DataGridTableMeta = { + tableName: "events", + columns: [{ name: "created_at", data_type: "timestamp", is_nullable: true }], + primaryKeys: [], + }; + const matrix: CellSelectionMatrix = { + rowIndexes: [0], + columnIndexes: [0], + columns: ["created_at"], + rows: [["2020-12-02T15:18:29"]], + }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ + text: "2020-12-02 15:18:29", + mimeType: "text/plain", + fileExtension: "txt", + rowCount: 1, + columnCount: 1, + }); + const state = createExportState(table, ["created_at"], matrix, ["2020-12-02T15:18:29"], undefined, [["2020-12-02T15:18:29"]], [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 0, "oracle", () => "2020-12-02 15:18:29"); + + await state.copyWithPreference("smart"); + + expect(extractDataGridSelection).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [["2020-12-02 15:18:29"]], + }), + ); + expect(copyToClipboard).toHaveBeenCalledWith("2020-12-02 15:18:29"); + }); + + it("copies OceanBase-Oracle temporal cells using the displayed value", async () => { + const table: DataGridTableMeta = { + tableName: "events", + columns: [{ name: "created_at", data_type: "date", is_nullable: true }], + primaryKeys: [], + }; + const matrix: CellSelectionMatrix = { + rowIndexes: [0], + columnIndexes: [0], + columns: ["created_at"], + rows: [["2020-12-02T15:18:29"]], + }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ + text: "2020-12-02 15:18:29", + mimeType: "text/plain", + fileExtension: "txt", + rowCount: 1, + columnCount: 1, + }); + const state = createExportState(table, ["created_at"], matrix, ["2020-12-02T15:18:29"], undefined, [["2020-12-02T15:18:29"]], [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 0, "oceanbase-oracle", () => "2020-12-02 15:18:29"); + + await state.copyCell(); + expect(copyToClipboard).toHaveBeenCalledWith("2020-12-02 15:18:29"); + + await state.copyWithPreference("smart"); + expect(extractDataGridSelection).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [["2020-12-02 15:18:29"]], + }), + ); + }); + + it("copies all rows with Oracle temporal cells formatted using the displayed value", async () => { + const table: DataGridTableMeta = { + tableName: "events", + columns: [{ name: "created_at", data_type: "timestamp", is_nullable: true }], + primaryKeys: [], + }; + const state = createExportState(table, ["created_at"], undefined, undefined, undefined, [["2020-12-02T15:18:29"]], [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 0, "oracle", () => "2020-12-02 15:18:29"); + + await state.copyAll(); + + expect(copyToClipboard).toHaveBeenCalledWith("created_at\n2020-12-02 15:18:29"); + }); + + it("copies rows as JSON with Oracle temporal cells formatted using the displayed value", async () => { + const table: DataGridTableMeta = { + tableName: "events", + columns: [{ name: "created_at", data_type: "timestamp", is_nullable: true }], + primaryKeys: [], + }; + const state = createExportState(table, ["created_at"], undefined, undefined, undefined, [["2020-12-02T15:18:29"]], [1], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 0, "oracle", () => "2020-12-02 15:18:29"); + + await state.copyRow(); + + expect(copyToClipboard).toHaveBeenCalledWith(JSON.stringify({ created_at: "2020-12-02 15:18:29" }, null, 2)); + }); + + it("keeps the raw Oracle temporal value for SQL extractors even with a display formatter", async () => { + const table: DataGridTableMeta = { + tableName: "events", + columns: [{ name: "created_at", data_type: "timestamp", is_nullable: true }], + primaryKeys: [], + }; + const matrix: CellSelectionMatrix = { + rowIndexes: [0], + columnIndexes: [0], + columns: ["created_at"], + rows: [["2020-12-02T15:18:29"]], + }; + const state = createExportState(table, ["created_at"], matrix, ["2020-12-02T15:18:29"], undefined, [["2020-12-02T15:18:29"]], [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 0, "oracle", () => "2020-12-02 15:18:29"); + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "SELECT * FROM events WHERE created_at = ...", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 }); + + expect(state.canCopyWithExtractor("sql-select")).toBe(true); + await state.copyWithExtractor("sql-select"); + + expect(extractDataGridSelection).toHaveBeenCalledWith( + expect.objectContaining({ + extractor: "sql-select", + rows: [["2020-12-02T15:18:29"]], + }), + ); + }); + it("copies all rows with the null literal for NULL cells", async () => { const text = "id\tname\n1\t\\N\n2\tAda"; const state = createExportState(editableTable, ["id", "name"], undefined, undefined, undefined, [ diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index d6044af377..0575edbbd3 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -504,10 +504,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { function externalizeRows(rows: CellValue[][], columnIndexes?: number[]): CellValue[][] { if (columnIndexes) { - return rows.map((row) => columnIndexes.map((colIdx) => (options.externalCellValue ? options.externalCellValue(row[colIdx], colIdx) : row[colIdx]))); + return rows.map((row) => columnIndexes.map((colIdx) => externalCellValue(row[colIdx], colIdx))); } - if (!options.externalCellValue) return rows; - return rows.map((row) => row.map((value, columnIndex) => options.externalCellValue!(value, columnIndex))); + return rows.map((row) => row.map((value, columnIndex) => externalCellValue(value, columnIndex))); } async function resultToExport( @@ -743,9 +742,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return binaryCellClipboardText(value, columnTypes.value?.[columnIndex], databaseType.value) ?? value; } - function externalCellValue(value: CellValue, columnIndex: number): CellValue { + function isOracleTemporalColumn(columnIndex: number, sourceIndex?: number): boolean { + const dbType = options.databaseType.value; + if (dbType !== "oracle" && dbType !== "oceanbase-oracle") return false; + const colType = options.columnTypes.value?.[columnIndex] ?? (sourceIndex !== undefined ? options.allColumnTypes?.value?.[sourceIndex] : undefined); + return isTemporalColumnType(colType); + } + + function externalCellValue(value: CellValue, columnIndex: number, sourceIndex?: number): CellValue { const externalValue = options.externalCellValue?.(value, columnIndex); - return externalValue === undefined ? value : externalValue; + const resolvedValue = externalValue === undefined ? value : externalValue; + if (resolvedValue !== null && resolvedValue !== undefined && options.displayValue) { + const resolvedSourceIndex = sourceIndex ?? visibleColumnIndexes.value[columnIndex] ?? columnIndex; + if (isOracleTemporalColumn(columnIndex, resolvedSourceIndex)) { + return options.displayValue(resolvedValue, resolvedSourceIndex); + } + } + return resolvedValue; } function rowToJsonObject(item: RowItem): Record { @@ -791,7 +804,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const val = resolvedItem?.data[columnIndex] ?? null; // 外部剪贴板呈现文本型 MySQL VARBINARY(NULL 也按空串输出);内部网格副本仍保留原 hex,保证回粘无损。 const rawValue = options.cellClipboardText?.(val, sourceIndex) ?? clipboardCellValue(binaryClipboardCellValue(val, columnIndex)); - const copyValue = options.databaseType.value === "oracle" && isTemporalColumnType(options.columnTypes.value?.[columnIndex]) ? (options.displayValue?.(val, sourceIndex) ?? rawValue) : rawValue; + const copyValue = isOracleTemporalColumn(columnIndex, sourceIndex) ? (options.displayValue?.(val, sourceIndex) ?? rawValue) : rawValue; await copyText(copyValue, { rows: [[val]] }); } @@ -936,6 +949,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { buildMongoUpdate: buildMongoExtractorUpdate, partialLoadInfo: partialLoadCopyInfo, canBuildMongoUpdate: canBuildMongoExtractorUpdate, + displayValue: options.displayValue, externalCellValue: (value, columnIndex) => { const externalValue = options.externalCellValue?.(value as CellValue, columnIndex); return externalValue === undefined ? value : externalValue; diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 3ce44e7866..0ad897995e 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -25,6 +25,8 @@ import { formatError } from "@/lib/backend/errorUtils"; import { parseJsonPreservingLargeNumbers, stringifyJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; import { tableMetaWithoutOptionalDatabaseQualifier } from "@/lib/table/tableSelectSql"; import { isPartiallyLoadedCopy, partialLoadCopyHintKey, type PartialLoadCopyInfo } from "@/lib/dataGrid/copyPartialLoadHint"; +import { isTemporalColumnType } from "@/lib/dataGrid/columnFormatter"; +import type { CellValue } from "@/lib/dataGrid/cellValue"; import type { DatabaseType } from "@/types/database"; interface ExtractorRowItem { @@ -74,6 +76,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; + displayValue?: (value: CellValue, columnIndex: number) => string; externalCellValue?: (value: unknown, columnIndex: number) => unknown; /** Present when the grid cannot guarantee the whole result set is loaded; drives the partial-copy hint. */ partialLoadInfo?: ComputedRef; @@ -101,16 +104,26 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } } - // 除 SQL 外的剪贴板展示格式都可把文本型 MySQL VARBINARY 从 `0x` 还原为原始字符串。 - // SQL 必须继续持有 hex 才能保证 round-trip;rawRows 则始终保存原值,供 DBX 内部网格回粘使用。 - function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, keepUnsafeJsonText: boolean, presentBinaryText: boolean, columnIndex: number): unknown { + function isOracleTemporal(columnType: string | undefined): boolean { + const dbType = options.databaseType.value; + return (dbType === "oracle" || dbType === "oceanbase-oracle") && isTemporalColumnType(columnType); + } + + // 除 SQL 外的剪贴板展示格式都可把文本型 MySQL VARBINARY 从 `0x` 还原为原始字符串, + // 并把 Oracle temporal 单元格渲染为与网格一致的显示文本;SQL 必须继续持有 hex / 原始时间值 + // (display 格式化会丢时区与高精度小数秒)才能保证 round-trip;rawRows 则始终保存原值,供 DBX 内部网格回粘使用。 + function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, keepUnsafeJsonText: boolean, presentBinaryText: boolean, columnIndex: number, sourceIndex?: number): unknown { if (presentBinaryText) { const text = binaryCellClipboardText(value, columnType, options.databaseType.value); if (text !== null) return text; } const normalized = normalizeValues ? normalizeCellValue(value, columnType, keepUnsafeJsonText) : value; const externalValue = options.externalCellValue?.(normalized, columnIndex); - return externalValue === undefined ? normalized : externalValue; + const resolvedValue = externalValue === undefined ? normalized : externalValue; + if (presentBinaryText && resolvedValue !== null && resolvedValue !== undefined && options.displayValue && isOracleTemporal(columnType)) { + return options.displayValue(resolvedValue as CellValue, sourceIndex ?? columnIndex); + } + return resolvedValue; } function selectionData(): SelectionData | null { @@ -235,7 +248,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const keepUnsafeJsonText = 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, keepUnsafeJsonText, presentBinaryText, index))); + const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, keepUnsafeJsonText, presentBinaryText, index, requiredSourceIndexes[index]))); // 「包含数据库名称」:复制/导出 SQL 时,提取器自身的勾选框与编辑器全局设置 // (`generateSqlIncludeDatabaseName`)任一开启都保留库名/模式名;后者还会影响 // 保存 SQL、侧栏导出等其它路径,因此这里取“或”,不覆盖全局偏好。 @@ -308,7 +321,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.keepUnsafeJsonText, limitedSource.presentBinaryText, index))); + const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.keepUnsafeJsonText, limitedSource.presentBinaryText, index, limitedSource.sourceColumnIndexes[index]))); const resolvedRequest = { ...request, rows }; requestSources.set(resolvedRequest, { ...limitedSource, rawRows }); return resolvedRequest;