🐛 fix(data-grid): 修复数据输出列序与时间精度问题

- 统一复制、导出、JSON/Text 视图按表格展示列序输出
- 表级导出改用显式列查询,避免 SELECT * 丢失界面列序
- 保留 datetime(3) 等时间字段的小数秒展示与复制输出
Refs #434
This commit is contained in:
Syngnat
2026-05-10 12:32:41 +08:00
parent baed7a2721
commit d26d7d2ff0
15 changed files with 271 additions and 2421 deletions

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGrid from './DataGrid';
import DataGrid, { formatCellDisplayText } from './DataGrid';
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
@@ -83,6 +83,10 @@ describe('DataGrid layout', () => {
expect(markup).toContain('当前页查找...');
});
it('preserves fractional seconds when rendering datetime values', () => {
expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456');
});
it('renders a DDL action for table data pages only', () => {
const tableMarkup = renderToStaticMarkup(
<DataGrid

View File

@@ -23,13 +23,13 @@ import {
arrayMove
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { ImportData, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import type { ColumnDefinition, IndexDefinition } from '../types';
import { v4 as generateUuid } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
@@ -51,6 +51,11 @@ import {
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
import {
buildDataGridSelectBaseSql,
pickDataGridOutputRows,
resolveDataGridOutputColumnNames,
} from './dataGridOutput';
import {
buildClipboardCsv,
buildClipboardJson,
@@ -181,7 +186,7 @@ const looksLikeDateTimeText = (val: string): boolean => {
);
};
// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing.
// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss[.fraction]` for display/editing.
// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`.
// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged.
const normalizeDateTimeString = (val: string) => {
@@ -200,16 +205,16 @@ const normalizeDateTimeString = (val: string) => {
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
const normalized = match ? `${match[1]} ${match[2]}` : val;
const normalized = match ? `${match[1]} ${match[2]}${match[3] || ''}` : val;
trimSimpleCache(normalizedDateTimeCache, DATE_TIME_CACHE_LIMIT);
normalizedDateTimeCache.set(val, normalized);
return normalized;
};
// --- Helper: Format Value ---
const formatCellDisplayText = (val: any): string => {
export const formatCellDisplayText = (val: any): string => {
try {
if (val === null) return 'NULL';
if (typeof val === 'object') {
@@ -1079,7 +1084,7 @@ const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverf
const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: '8px 8px 8px 8px' };
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
onApplyQuickWhereCondition,
scrollSnapshot, onScrollSnapshotChange
@@ -1206,6 +1211,14 @@ const DataGrid: React.FC<DataGridProps> = ({
setDisplayColumnNames(allOrderedColumnNames.filter(col => !hiddenSet.has(col)));
}, [allOrderedColumnNames, localHiddenColumns]);
const displayOutputColumnNames = useMemo(
() => resolveDataGridOutputColumnNames(
displayColumnNames.length > 0 || allOrderedColumnNames.length > 0 ? displayColumnNames : visibleColumnNames,
GONAVI_ROW_KEY,
),
[displayColumnNames, allOrderedColumnNames, visibleColumnNames]
);
// Handle Dragging
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -1510,15 +1523,9 @@ const DataGrid: React.FC<DataGridProps> = ({
const exportData = async (rows: any[], format: string) => {
const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0);
try {
const cleanRows = rows.map((row) => {
const next: Record<string, any> = {};
displayColumnNames.forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
});
const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames);
// Pass tableName (or 'export') as default filename
const res = await ExportData(cleanRows, displayColumnNames, tableName || 'export', format);
const res = await ExportData(cleanRows, displayOutputColumnNames, tableName || 'export', format);
if (res.success) {
void message.success("导出成功");
} else if (res.message !== "已取消") {
@@ -3435,26 +3442,15 @@ const DataGrid: React.FC<DataGridProps> = ({
const jsonViewText = useMemo(() => {
if (viewMode !== 'json') return '';
const cleanRows = mergedDisplayData.map((row) => {
const next: Record<string, any> = {};
visibleColumnNames.forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return normalizeValueForJsonView(next);
});
const cleanRows = pickDataGridOutputRows(mergedDisplayData, displayOutputColumnNames)
.map((row) => normalizeValueForJsonView(row));
return JSON.stringify(cleanRows, null, 2);
}, [viewMode, mergedDisplayData, visibleColumnNames]);
}, [viewMode, mergedDisplayData, displayOutputColumnNames]);
const textViewRows = useMemo(() => {
if (viewMode !== 'text') return [];
return mergedDisplayData.map((row) => {
const next: Record<string, any> = {};
visibleColumnNames.forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
});
}, [viewMode, mergedDisplayData, visibleColumnNames]);
return pickDataGridOutputRows(mergedDisplayData, displayOutputColumnNames);
}, [viewMode, mergedDisplayData, displayOutputColumnNames]);
const currentTextRow = useMemo(() => {
if (viewMode !== 'text') return null;
@@ -3919,7 +3915,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const copiedRows = buildCopiedRowsForPaste({
rows: mergedDisplayData as Array<Record<string, any>>,
selectedRowKeys,
columnNames: visibleColumnNames,
columnNames: displayOutputColumnNames,
rowKeyField: GONAVI_ROW_KEY,
rowKeyToString: rowKeyStr,
});
@@ -3930,7 +3926,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setCopiedRowsForPaste(copiedRows);
void message.success(`已复制 ${copiedRows.length} 行,可粘贴为新增行`);
}, [selectedRowKeys, mergedDisplayData, visibleColumnNames, rowKeyStr]);
}, [selectedRowKeys, mergedDisplayData, displayOutputColumnNames, rowKeyStr]);
const handlePasteCopiedRowsAsNew = useCallback(() => {
if (copiedRowsForPaste.length === 0) {
@@ -3940,7 +3936,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const nextRows = buildPastedRowsFromCopiedRows({
rows: copiedRowsForPaste,
columnNames: visibleColumnNames,
columnNames: displayOutputColumnNames,
rowKeyField: GONAVI_ROW_KEY,
createRowKey: (index) => {
pastedRowSequenceRef.current += 1;
@@ -3956,7 +3952,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setAddedRows(prev => [...prev, ...nextRows]);
setSelectedRowKeys(nextRows.map(row => row[GONAVI_ROW_KEY]));
void message.success(`已粘贴 ${nextRows.length} 行为新增行,请检查后提交事务`);
}, [copiedRowsForPaste, visibleColumnNames]);
}, [copiedRowsForPaste, displayOutputColumnNames]);
const handleDeleteSelected = () => {
setDeletedRowKeys(prev => {
@@ -4050,16 +4046,16 @@ const DataGrid: React.FC<DataGridProps> = ({
pickRowsForClipboard({
rows: mergedDisplayData as Array<Record<string, unknown>>,
selectedRowKeys,
columnNames: visibleColumnNames,
columnNames: displayOutputColumnNames,
rowKeyField: GONAVI_ROW_KEY,
rowKeyToString: rowKeyStr,
})
), [mergedDisplayData, selectedRowKeys, visibleColumnNames, rowKeyStr]);
), [mergedDisplayData, selectedRowKeys, displayOutputColumnNames, rowKeyStr]);
const getClipboardColumnNames = useCallback((rows: Array<Record<string, unknown>>) => {
if (rows.length === 0) return [];
return visibleColumnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY);
}, [visibleColumnNames]);
return displayOutputColumnNames;
}, [displayOutputColumnNames]);
const handleCopyQueryResultCsv = useCallback(() => {
const rows = getClipboardRows();
@@ -4199,7 +4195,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return null;
}
const records = getTargets(record);
const orderedCols = visibleColumnNames.filter(c => c !== GONAVI_ROW_KEY);
const orderedCols = displayOutputColumnNames;
if (mode === 'insert') {
return records.map((row: any) => buildCopyInsertSQL({
dbType,
@@ -4248,7 +4244,7 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [
supportsCopyInsert,
getTargets,
visibleColumnNames,
displayOutputColumnNames,
dbType,
tableName,
columnTypeMapByLowerName,
@@ -4277,19 +4273,13 @@ const DataGrid: React.FC<DataGridProps> = ({
const handleCopyJson = useCallback((record: any) => {
const records = getTargets(record);
const cleanRecords = records.map((r: any) => {
const next: Record<string, any> = {};
visibleColumnNames.forEach((columnName) => {
next[columnName] = r?.[columnName];
});
return next;
});
const cleanRecords = pickDataGridOutputRows(records, displayOutputColumnNames);
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
}, [getTargets, visibleColumnNames, copyToClipboard]);
}, [getTargets, displayOutputColumnNames, copyToClipboard]);
const handleCopyCsv = useCallback((record: any) => {
const records = getTargets(record);
const orderedCols = visibleColumnNames.filter(c => c !== GONAVI_ROW_KEY);
const orderedCols = displayOutputColumnNames;
const header = orderedCols.map(c => `"${c}"`).join(',');
const lines = records.map((r: any) => {
const values = orderedCols.map(c => {
@@ -4302,7 +4292,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return values.join(',');
});
copyToClipboard([header, ...lines].join('\n'));
}, [getTargets, visibleColumnNames, copyToClipboard]);
}, [getTargets, displayOutputColumnNames, copyToClipboard]);
const buildConnConfig = useCallback(() => {
if (!connectionId) return null;
@@ -4362,7 +4352,12 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!tableName || !pagination) return '';
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const baseSql = buildDataGridSelectBaseSql({
dbType,
tableName,
columnNames: displayOutputColumnNames,
whereSql: whereSQL,
});
const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = String(dbType || '').trim().toLowerCase();
const hasSortForBuffer = hasExplicitSort(sortInfo);
@@ -4372,7 +4367,36 @@ const DataGrid: React.FC<DataGridProps> = ({
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
}, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]);
const buildAllRowsSql = useCallback((dbType: string) => {
if (!tableName) return '';
return buildDataGridSelectBaseSql({
dbType,
tableName,
columnNames: displayOutputColumnNames,
});
}, [tableName, displayOutputColumnNames]);
const buildFilteredAllSql = useCallback((dbType: string) => {
if (!tableName) return '';
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
if (!whereSQL) return '';
let sql = buildDataGridSelectBaseSql({
dbType,
tableName,
columnNames: displayOutputColumnNames,
whereSql: whereSQL,
});
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = String(dbType || '').trim().toLowerCase();
const hasSortForBuffer = hasExplicitSort(sortInfo);
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]);
// Context Menu Export
const handleExportSelected = useCallback(async (format: string, record: any) => {
@@ -4406,9 +4430,14 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
const sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} WHERE ${pkWhere}`;
const sql = buildDataGridSelectBaseSql({
dbType,
tableName,
columnNames: displayOutputColumnNames,
whereSql: `WHERE ${pkWhere}`,
});
await exportByQuery(sql, format, tableName || 'export');
}, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery]);
}, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery, displayOutputColumnNames]);
// Export
const handleExport = async (format: string) => {
@@ -4423,12 +4452,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// 查询结果页导出统一按当前结果集(已加载数据)导出,避免再次执行原 SQL 造成大数据导出或长时间阻塞。
if (isQueryResultExport) {
const sql = String(resultSql || '').trim();
if (!hasChanges && supportsSqlQueryExport && sql) {
await exportByQuery(sql, format, tableName || 'query_result');
} else {
await exportData(mergedDisplayData, format);
}
await exportData(mergedDisplayData, format);
return;
}
@@ -4440,19 +4464,9 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!tableName) return;
const config = buildConnConfig();
if (!config) return;
const hide = message.loading(`正在导出全部数据...`, 0);
try {
const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName || '', tableName, format);
if (res.success) {
void message.success("导出成功");
} else if (res.message !== "已取消") {
void message.error("导出失败: " + res.message);
}
} catch (e: any) {
void message.error("导出失败: " + (e?.message || String(e)));
} finally {
hide();
}
const sql = buildAllRowsSql(resolveDataSourceType(config));
if (!sql) return;
await exportByQuery(sql, format, tableName || 'export');
};
const handlePage = async () => {
instance.destroy();
@@ -4505,11 +4519,18 @@ const DataGrid: React.FC<DataGridProps> = ({
void message.error('当前数据源不支持按筛选结果导出');
return;
}
const config = buildConnConfig();
if (!config) return;
if (hasChanges) {
void message.warning("当前存在未提交修改,筛选结果导出基于数据库已提交数据。");
}
await exportByQuery(filteredExportSql, format, `${tableName || 'export'}_filtered`);
const sql = buildFilteredAllSql(resolveDataSourceType(config));
if (!sql) {
void message.warning('当前未应用筛选条件');
return;
}
await exportByQuery(sql, format, `${tableName || 'export'}_filtered`);
};
const handleImport = async () => {
@@ -4655,7 +4676,7 @@ const DataGrid: React.FC<DataGridProps> = ({
{ key: 'json', label: 'JSON', onClick: handleCopyQueryResultJson },
{ key: 'markdown', label: 'Markdown', onClick: handleCopyQueryResultMarkdown },
];
const canCopyQueryResult = isQueryResultExport && mergedDisplayData.length > 0 && visibleColumnNames.length > 0;
const canCopyQueryResult = isQueryResultExport && mergedDisplayData.length > 0 && displayOutputColumnNames.length > 0;
const columnInfoSettingContent = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 200, maxWidth: 300 }}>
@@ -6139,7 +6160,7 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
</div>
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
{currentTextRow ? displayColumnNames.map((col) => (
{currentTextRow ? displayOutputColumnNames.map((col) => (
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
{col} :

View File

@@ -37,4 +37,19 @@ describe('dataGridClipboardExport', () => {
expect(rows).toEqual([{ total: 2 }]);
});
it('keeps copied row fields in the provided display column order', () => {
const rows = pickRowsForClipboard({
rows: [
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
],
selectedRowKeys: [],
columnNames: ['name', 'id'],
rowKeyField: '__gonavi_row_key__',
});
expect(Object.keys(rows[0])).toEqual(['name', 'id']);
expect(buildClipboardCsv(rows, ['name', 'id'])).toBe('"name","id"\n"alpha","1"');
expect(buildClipboardJson(rows)).toBe('[\n {\n "name": "alpha",\n "id": 1\n }\n]');
});
});

View File

@@ -46,6 +46,38 @@ describe('buildCopyInsertSQL', () => {
);
});
it('preserves fractional seconds for MySQL datetime precision columns', () => {
const sql = buildCopyInsertSQL({
dbType: 'mysql',
tableName: 'events',
orderedCols: ['created_at'],
record: {
created_at: '2026-05-10T09:12:33.456+08:00',
},
columnTypesByLowerName: {
created_at: 'datetime(3)',
},
});
expect(sql).toBe(
"INSERT INTO `events` (`created_at`) VALUES ('2026-05-10 09:12:33.456');",
);
});
it('uses ordered columns for copy-as-insert output', () => {
const sql = buildCopyInsertSQL({
dbType: 'mysql',
tableName: 'users',
orderedCols: ['name', 'id'],
record: {
id: 7,
name: 'Ada',
},
});
expect(sql).toBe("INSERT INTO `users` (`name`, `id`) VALUES ('Ada', '7');");
});
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',

View File

@@ -51,9 +51,9 @@ const normalizeDateTimeString = (val: string): string => {
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
return match ? `${match[1]} ${match[2]}` : val;
return match ? `${match[1]} ${match[2]}${match[3] || ''}` : val;
};
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
@@ -66,13 +66,14 @@ const normalizeTimezoneAwareDateTimeString = (val: string): string => {
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
if (!match) {
return val;
}
const suffix = match[3] || '';
return `${match[1]} ${match[2]}${suffix}`;
const fractional = match[3] || '';
const suffix = match[4] || '';
return `${match[1]} ${match[2]}${fractional}${suffix}`;
};
const isTemporalColumnType = (columnType?: string): boolean => {
@@ -165,22 +166,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => {
return String(value);
};
const hasFractionalSeconds = (value: string): boolean => /\d{2}:\d{2}:\d{2}\.\d+/.test(value);
const stripFractionalSeconds = (value: string): string => (
value.replace(/(\d{2}:\d{2}:\d{2})\.\d+/, '$1')
);
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
if (!isTemporalColumnType(columnType)) {
return null;
}
const normalized = toNormalizedLiteralText(value, columnType);
const escaped = escapeLiteral(normalized);
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
const rawType = String(columnType || '').toLowerCase();
const isTimestamp = rawType.includes('timestamp');
const oracleValue = isTimestamp ? normalized : stripFractionalSeconds(normalized);
const escaped = escapeLiteral(oracleValue);
if (/^\d{4}-\d{2}-\d{2}$/.test(oracleValue)) {
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
}
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(oracleValue)) {
const compactOffset = oracleValue.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
const temporalFormat = hasFractionalSeconds(oracleValue)
? 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM'
: 'YYYY-MM-DD HH24:MI:SSTZH:TZM';
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', '${temporalFormat}')`;
}
const rawType = String(columnType || '').toLowerCase();
if (rawType.includes('timestamp')) {
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
if (isTimestamp) {
const temporalFormat = hasFractionalSeconds(oracleValue)
? 'YYYY-MM-DD HH24:MI:SS.FF'
: 'YYYY-MM-DD HH24:MI:SS';
return `TO_TIMESTAMP('${escaped}', '${temporalFormat}')`;
}
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
};

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import {
buildDataGridSelectBaseSql,
pickDataGridOutputRows,
resolveDataGridOutputColumnNames,
} from './dataGridOutput';
const rowKeyField = '__gonavi_row_key__';
describe('dataGridOutput helpers', () => {
it('resolves exportable columns in display order without the internal row key', () => {
expect(resolveDataGridOutputColumnNames(['name', rowKeyField, 'id'], rowKeyField)).toEqual(['name', 'id']);
});
it('keeps exact column names when resolving output order', () => {
expect(resolveDataGridOutputColumnNames([' full name ', 'id'], rowKeyField)).toEqual([' full name ', 'id']);
});
it('picks row values in display column order', () => {
const rows = pickDataGridOutputRows([
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
], ['name', 'id']);
expect(Object.keys(rows[0])).toEqual(['name', 'id']);
expect(rows[0]).toEqual({ name: 'alpha', id: 1 });
});
it('builds table SELECT SQL with explicit display columns', () => {
expect(buildDataGridSelectBaseSql({
dbType: 'mysql',
tableName: 'users',
columnNames: ['name', 'id'],
whereSql: "WHERE `id` = '7'",
})).toBe("SELECT `name`, `id` FROM `users` WHERE `id` = '7'");
});
});

View File

@@ -0,0 +1,41 @@
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
export const resolveDataGridOutputColumnNames = (
displayColumnNames: string[],
rowKeyField: string,
): string[] => (
(displayColumnNames || [])
.map((columnName) => String(columnName ?? ''))
.filter((columnName) => columnName && columnName !== rowKeyField)
);
export const pickDataGridOutputRows = (
rows: Array<Record<string, any>>,
columnNames: string[],
): Array<Record<string, any>> => (
(rows || []).map((row) => {
const next: Record<string, any> = {};
(columnNames || []).forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
})
);
export const buildDataGridSelectBaseSql = ({
dbType,
tableName,
columnNames,
whereSql = '',
}: {
dbType: string;
tableName: string;
columnNames: string[];
whereSql?: string;
}): string => {
const selectList = columnNames.length > 0
? columnNames.map((columnName) => quoteIdentPart(dbType, columnName)).join(', ')
: '*';
const wherePart = String(whereSql || '').trim();
return `SELECT ${selectList} FROM ${quoteQualifiedIdent(dbType, tableName)}${wherePart ? ` ${wherePart}` : ''}`;
};

View File

@@ -22,6 +22,20 @@ describe('dataGridRowClipboard', () => {
]);
});
it('copies row fields in display column order', () => {
const copiedRows = buildCopiedRowsForPaste({
rows: [
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
],
selectedRowKeys: ['row-1'],
columnNames: ['name', 'id'],
rowKeyField,
});
expect(Object.keys(copiedRows[0])).toEqual(['name', 'id']);
expect(copiedRows[0]).toEqual({ name: 'alpha', id: 1 });
});
it('builds pasted rows as new rows with fresh internal keys', () => {
const pastedRows = buildPastedRowsFromCopiedRows({
rows: [