mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-16 11:39:38 +08:00
🐛 fix(data-grid): 修复数据输出列序与时间精度问题
- 统一复制、导出、JSON/Text 视图按表格展示列序输出 - 表级导出改用显式列查询,避免 SELECT * 丢失界面列序 - 保留 datetime(3) 等时间字段的小数秒展示与复制输出 Refs #434
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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} :
|
||||
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')`;
|
||||
};
|
||||
|
||||
37
frontend/src/components/dataGridOutput.test.ts
Normal file
37
frontend/src/components/dataGridOutput.test.ts
Normal 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'");
|
||||
});
|
||||
});
|
||||
41
frontend/src/components/dataGridOutput.ts
Normal file
41
frontend/src/components/dataGridOutput.ts
Normal 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}` : ''}`;
|
||||
};
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user