From 29b96719d5514d16ef8516fe1e8efcbb206676a4 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:29:03 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sql):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=AD=97=E6=AE=B5=E5=A4=8D=E5=88=B6=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E5=87=BASQL=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/DataGrid.tsx | 30 ++-- frontend/src/components/DataSyncModal.tsx | 31 ++++- .../src/components/dataGridCopyInsert.test.ts | 61 ++++++++ frontend/src/components/dataGridCopyInsert.ts | 131 ++++++++++++++++++ internal/app/methods_file.go | 10 +- internal/app/methods_file_export_test.go | 14 ++ internal/sync/preview.go | 10 ++ 7 files changed, 269 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/dataGridCopyInsert.test.ts create mode 100644 frontend/src/components/dataGridCopyInsert.ts diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index ab5f5d6..669ea9a 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -33,6 +33,7 @@ import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout'; +import { buildCopyInsertSQL, normalizeTemporalLiteralText } from './dataGridCopyInsert'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -995,6 +996,7 @@ const DataGrid: React.FC = ({ const selectionColumnWidth = 46; const currentConnConfig = connections.find(c => c.id === connectionId)?.config; const dataSourceCaps = getDataSourceCapabilities(currentConnConfig); + const dbType = dataSourceCaps.type; const isDuckDBConnection = dataSourceCaps.type === 'duckdb'; const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport; @@ -1336,6 +1338,16 @@ const DataGrid: React.FC = ({ return next; }, [columnMetaMap]); + const columnTypeMapByLowerName = useMemo(() => { + const next: Record = {}; + Object.entries(columnMetaMapByLowerName).forEach(([name, meta]) => { + const type = String(meta?.type || '').trim(); + if (!name || !type) return; + next[name] = type; + }); + return next; + }, [columnMetaMapByLowerName]); + const normalizeCommitCellValue = useCallback( (columnName: string, value: any, mode: 'insert' | 'update') => { if (value === undefined) return undefined; @@ -1357,7 +1369,7 @@ const DataGrid: React.FC = ({ // INSERT 空时间值直接忽略字段,让数据库默认值生效;UPDATE 空时间值转 NULL。 return mode === 'insert' ? undefined : null; } - return normalizeDateTimeString(value); + return normalizeTemporalLiteralText(value, meta?.type, true); } return value; @@ -3501,17 +3513,15 @@ const DataGrid: React.FC = ({ // 使用 columnNames 保持表定义的字段顺序,而非 Object.keys() 的不确定顺序 const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY); const sqlList = records.map((r: any) => { - const values = orderedCols.map(c => { - const v = r[c]; - if (v === null || v === undefined) return 'NULL'; - const str = typeof v === 'string' ? normalizeDateTimeString(v) : String(v); - const escaped = str.replace(/'/g, "''"); - return `'${escaped}'`; + return buildCopyInsertSQL({ + dbType, + tableName, + orderedCols, + record: r, + columnTypesByLowerName: columnTypeMapByLowerName, }); - const targetTable = tableName || 'table'; - return `INSERT INTO \`${targetTable}\` (${orderedCols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`; }); - copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, tableName, columnNames, getTargets, copyToClipboard]); + copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, columnNames, getTargets, copyToClipboard, dbType, tableName, columnTypeMapByLowerName]); const handleCopyJson = useCallback((record: any) => { const records = getTargets(record); diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index 1389be7..7775c08 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -6,6 +6,7 @@ import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview import { SavedConnection } from '../types'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert'; const { Title, Text } = Typography; const { Step } = Steps; @@ -74,7 +75,10 @@ const toSqlLiteral = (value: any, dbType: string): string => { return value ? 'TRUE' : 'FALSE'; } if (value instanceof Date) { - return `'${value.toISOString().replace(/'/g, "''")}'`; + return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`; + } + if (typeof value === 'string') { + return `'${value.replace(/'/g, "''")}'`; } if (typeof value === 'object') { try { @@ -86,6 +90,20 @@ const toSqlLiteral = (value: any, dbType: string): string => { return `'${String(value).replace(/'/g, "''")}'`; }; +const toTypedSqlLiteral = (value: any, dbType: string, columnType?: string): string => { + if (typeof value === 'string') { + const normalized = normalizeTemporalLiteralText(value, columnType, false); + return toSqlLiteral(normalized, dbType); + } + if (value instanceof Date) { + const normalized = String(columnType || '').trim() + ? formatLocalDateTimeLiteral(value) + : value.toISOString(); + return toSqlLiteral(normalized, dbType); + } + return toSqlLiteral(value, dbType); +}; + const resolveRedisDbIndex = (raw?: string): number => { const value = Number(String(raw || '').trim()); return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0; @@ -100,6 +118,9 @@ const buildSqlPreview = ( if (!previewData || !tableName) return { sqlText: '', statementCount: 0 }; const tableExpr = quoteSqlTable(dbType, tableName); const pkCol = String(previewData.pkColumn || 'id'); + const columnTypesByLowerName = previewData?.columnTypes && typeof previewData.columnTypes === 'object' + ? previewData.columnTypes as Record + : {}; const statements: string[] = []; const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : []; @@ -118,7 +139,7 @@ const buildSqlPreview = ( const columns = Object.keys(row); if (columns.length === 0) return; const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', '); - const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', '); + const valExpr = columns.map((c) => toTypedSqlLiteral(row[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])).join(', '); statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`); }); } @@ -134,10 +155,10 @@ const buildSqlPreview = ( const setCols = changedColumns.filter((c: string) => String(c) !== pkCol); if (setCols.length === 0) return; const setExpr = setCols - .map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`) + .map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`) .join(', '); statements.push( - `UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`, + `UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`, ); }); } @@ -147,7 +168,7 @@ const buildSqlPreview = ( const pk = String(rowWrap?.pk ?? ''); if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return; statements.push( - `DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`, + `DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`, ); }); } diff --git a/frontend/src/components/dataGridCopyInsert.test.ts b/frontend/src/components/dataGridCopyInsert.test.ts new file mode 100644 index 0000000..01729e1 --- /dev/null +++ b/frontend/src/components/dataGridCopyInsert.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCopyInsertSQL } from './dataGridCopyInsert'; + +describe('buildCopyInsertSQL', () => { + it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => { + const sql = buildCopyInsertSQL({ + dbType: 'postgres', + tableName: 'public.OrderLog', + orderedCols: ['CreatedAt', 'note'], + record: { + CreatedAt: '2026-01-21T18:32:26+08:00', + note: "O'Brien", + }, + columnTypesByLowerName: { + createdat: 'timestamp without time zone', + note: 'text', + }, + }); + + expect(sql).toBe( + `INSERT INTO public."OrderLog" ("CreatedAt", note) VALUES ('2026-01-21 18:32:26', 'O''Brien');`, + ); + }); + + it('keeps timezone offsets for timezone-aware PostgreSQL columns while still removing the T separator', () => { + const sql = buildCopyInsertSQL({ + dbType: 'postgres', + tableName: 'public.audit_log', + orderedCols: ['created_at'], + record: { + created_at: '2026-01-21T18:32:26+08:00', + }, + columnTypesByLowerName: { + created_at: 'timestamp with time zone', + }, + }); + + expect(sql).toBe( + `INSERT INTO public.audit_log (created_at) VALUES ('2026-01-21 18:32:26+08:00');`, + ); + }); + + it('keeps RFC3339-looking text unchanged for non-temporal columns', () => { + const sql = buildCopyInsertSQL({ + dbType: 'postgres', + tableName: 'public.audit_log', + orderedCols: ['payload'], + record: { + payload: '2026-01-21T18:32:26+08:00', + }, + columnTypesByLowerName: { + payload: 'text', + }, + }); + + expect(sql).toBe( + `INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`, + ); + }); +}); diff --git a/frontend/src/components/dataGridCopyInsert.ts b/frontend/src/components/dataGridCopyInsert.ts new file mode 100644 index 0000000..3034584 --- /dev/null +++ b/frontend/src/components/dataGridCopyInsert.ts @@ -0,0 +1,131 @@ +import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; + +type BuildCopyInsertSQLParams = { + dbType: string; + tableName?: string; + orderedCols: string[]; + record: Record; + columnTypesByLowerName?: Record; +}; + +const looksLikeDateTimeText = (val: string): boolean => { + if (!val) return false; + const len = val.length; + if (len < 19 || len > 64) return false; + const charCode0 = val.charCodeAt(0); + if (charCode0 < 48 || charCode0 > 57) return false; + return ( + val[4] === '-' && + val[7] === '-' && + (val[10] === ' ' || val[10] === 'T') && + val[13] === ':' && + val[16] === ':' + ); +}; + +const normalizeDateTimeString = (val: string): string => { + if (!looksLikeDateTimeText(val)) { + return val; + } + + if (/^0{4}-0{2}-0{2}/.test(val)) { + return val; + } + + 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_\/+-]+)?)?$/ + ); + return match ? `${match[1]} ${match[2]}` : val; +}; + +const normalizeTimezoneAwareDateTimeString = (val: string): string => { + if (!looksLikeDateTimeText(val)) { + return val; + } + + if (/^0{4}-0{2}-0{2}/.test(val)) { + return val; + } + + 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_\/+-]+)?)?$/ + ); + if (!match) { + return val; + } + const suffix = match[3] || ''; + return `${match[1]} ${match[2]}${suffix}`; +}; + +const isTemporalColumnType = (columnType?: string): boolean => { + const raw = String(columnType || '').trim().toLowerCase(); + if (!raw) return false; + if (raw.includes('datetime') || raw.includes('timestamp') || raw.includes('timestamptz')) return true; + const base = raw.split(/[ (]/)[0]; + return base === 'date' || base === 'time' || base === 'timetz' || base === 'year'; +}; + +const isTimezoneAwareColumnType = (columnType?: string): boolean => { + const raw = String(columnType || '').trim().toLowerCase(); + if (!raw) return false; + return ( + raw.includes('with time zone') || + raw.includes('with timezone') || + raw.includes('datetimeoffset') || + raw.includes('timestamptz') || + raw.includes('timetz') + ); +}; + +export const normalizeTemporalLiteralText = ( + value: string, + columnType?: string, + normalizeWhenTypeMissing = false, +): string => { + const rawType = String(columnType || '').trim(); + if (!rawType) { + return normalizeWhenTypeMissing ? normalizeDateTimeString(value) : value; + } + if (!isTemporalColumnType(rawType)) { + return value; + } + return isTimezoneAwareColumnType(rawType) + ? normalizeTimezoneAwareDateTimeString(value) + : normalizeDateTimeString(value); +}; + +export const formatLocalDateTimeLiteral = (value: Date): string => { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + const hour = String(value.getHours()).padStart(2, '0'); + const minute = String(value.getMinutes()).padStart(2, '0'); + const second = String(value.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +}; + +export const buildCopyInsertSQL = ({ + dbType, + tableName, + orderedCols, + record, + columnTypesByLowerName = {}, +}: BuildCopyInsertSQLParams): string => { + const targetTable = quoteQualifiedIdent(dbType, tableName || 'table'); + const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col)); + const values = orderedCols.map((col) => { + const value = record?.[col]; + if (value === null || value === undefined) return 'NULL'; + + const columnType = columnTypesByLowerName[String(col || '').toLowerCase()]; + const raw = + typeof value === 'string' + ? normalizeTemporalLiteralText(value, columnType, true) + : value instanceof Date + ? formatLocalDateTimeLiteral(value) + : String(value); + return `'${escapeLiteral(raw)}'`; + }); + + return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`; +}; diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 3d02edf..d2ad585 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -574,7 +574,7 @@ func isDateTimeColumnType(columnType string) bool { if typ == "" { return false } - return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") + return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") || strings.Contains(typ, "timestamptz") } func isTimeOnlyColumnType(columnType string) bool { @@ -585,7 +585,7 @@ func isTimeOnlyColumnType(columnType string) bool { if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") { return false } - return strings.Contains(typ, "time") + return strings.Contains(typ, "time") || strings.Contains(typ, "timetz") } func isDateOnlyColumnType(dbType, columnType string) bool { @@ -1717,6 +1717,10 @@ func dumpTableSQL( if err != nil { return err } + columnTypeMap := map[string]string{} + if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil { + columnTypeMap = buildImportColumnTypeMap(defs) + } if len(data) == 0 { if _, err := w.WriteString("-- (0 rows)\n"); err != nil { return err @@ -1733,7 +1737,7 @@ func dumpTableSQL( for _, row := range data { values := make([]string, 0, len(columns)) for _, c := range columns { - values = append(values, formatSQLValue(config.Type, row[c])) + values = append(values, formatImportSQLValue(config.Type, columnTypeMap[normalizeColumnName(c)], row[c])) } if _, err := w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", "))); err != nil { return err diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index 5ddaf9c..998d85d 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -273,3 +273,17 @@ func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) { t.Fatalf("html 表头未正确转义: %s", content) } } + +func TestFormatImportSQLValue_NormalizesTimestampWithoutTimezone(t *testing.T) { + got := formatImportSQLValue("postgres", "timestamp without time zone", "2026-01-21T18:32:26+08:00") + if got != "'2026-01-21 18:32:26'" { + t.Fatalf("时间字面量归一化异常,want=%q got=%q", "'2026-01-21 18:32:26'", got) + } +} + +func TestFormatImportSQLValue_LeavesTextLiteralUntouched(t *testing.T) { + got := formatImportSQLValue("postgres", "text", "2026-01-21T18:32:26+08:00") + if got != "'2026-01-21T18:32:26+08:00'" { + t.Fatalf("文本字段不应被归一化,want=%q got=%q", "'2026-01-21T18:32:26+08:00'", got) + } +} diff --git a/internal/sync/preview.go b/internal/sync/preview.go index 2ce6434..592d0de 100644 --- a/internal/sync/preview.go +++ b/internal/sync/preview.go @@ -21,6 +21,7 @@ type PreviewUpdateRow struct { type TableDiffPreview struct { Table string `json:"table"` PKColumn string `json:"pkColumn"` + ColumnTypes map[string]string `json:"columnTypes,omitempty"` TotalInserts int `json:"totalInserts"` TotalUpdates int `json:"totalUpdates"` TotalDeletes int `json:"totalDeletes"` @@ -112,6 +113,7 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta out := TableDiffPreview{ Table: tableName, PKColumn: pkCol, + ColumnTypes: make(map[string]string, len(cols)), TotalInserts: 0, TotalUpdates: 0, TotalDeletes: 0, @@ -119,6 +121,14 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta Updates: make([]PreviewUpdateRow, 0), Deletes: make([]PreviewRow, 0), } + for _, col := range cols { + name := strings.ToLower(strings.TrimSpace(col.Name)) + typ := strings.TrimSpace(col.Type) + if name == "" || typ == "" { + continue + } + out.ColumnTypes[name] = typ + } sourcePKSet := make(map[string]struct{}, len(sourceRows)) for _, sRow := range sourceRows {