mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(oracle): copy and snapshot temporal cells in display format
This commit is contained in:
@@ -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;
|
||||
|
||||
+35
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user