fix(oracle): copy and snapshot temporal cells in display format

This commit is contained in:
lxk955
2026-10-12 23:38:22 +08:00
committed by GitHub
parent f5f58d4ae5
commit cd77b06a59
5 changed files with 197 additions and 17 deletions
@@ -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;
@@ -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();
@@ -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, [
@@ -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<string, unknown> {
@@ -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;
@@ -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<boolean> | Ref<boolean>;
copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) => Promise<boolean>;
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<PartialLoadCopyInfo | null>;
@@ -101,16 +104,26 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
}
}
// 除 SQL 外的剪贴板展示格式都可把文本型 MySQL VARBINARY 从 `0x<hex>` 还原为原始字符串。
// 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<hex>` 还原为原始字符串,
// 并把 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;