🐛 fix(datagrid-mysql): 修复MySQL行编辑时datetime空值提交失败

- 前端按列类型归一化 temporal 字段,INSERT 空值跳过字段、UPDATE 空值转 NULL
- 后端 ApplyChanges 增加 temporal 字段兜底,避免空字符串写入 datetime/timestamp
- 新增全默认值插入路径,兼容 CURRENT_TIMESTAMP 等默认值场景
- refs #113
This commit is contained in:
Syngnat
2026-02-26 14:13:27 +08:00
parent a435d62d3b
commit 66a3113fa8
2 changed files with 139 additions and 7 deletions

View File

@@ -90,6 +90,14 @@ const normalizeDateTimeString = (val: string) => {
return `${match[1]} ${match[2]}`;
};
const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'year';
};
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
try {
@@ -764,6 +772,35 @@ const DataGrid: React.FC<DataGridProps> = ({
return next;
}, [columnMetaMap]);
const normalizeCommitCellValue = useCallback(
(columnName: string, value: any, mode: 'insert' | 'update') => {
if (value === undefined) return undefined;
const normalizedName = String(columnName || '').trim();
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
const temporal = isTemporalColumnType(meta?.type);
if (!temporal) {
return value;
}
if (value === null) {
return null;
}
if (typeof value === 'string') {
const raw = value.trim();
if (raw === '') {
// INSERT 空时间值直接忽略字段让数据库默认值生效UPDATE 空时间值转 NULL。
return mode === 'insert' ? undefined : null;
}
return normalizeDateTimeString(value);
}
return value;
},
[columnMetaMap, columnMetaMapByLowerName]
);
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
const normalizedName = String(name || '');
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
@@ -1814,7 +1851,17 @@ const DataGrid: React.FC<DataGridProps> = ({
const updates: any[] = [];
const deletes: any[] = [];
addedRows.forEach(row => { const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; inserts.push(vals); });
addedRows.forEach(row => {
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row;
const normalizedValues: Record<string, any> = {};
Object.entries(vals).forEach(([col, val]) => {
const normalizedVal = normalizeCommitCellValue(col, val, 'insert');
if (normalizedVal !== undefined) {
normalizedValues[col] = normalizedVal;
}
});
inserts.push(normalizedValues);
});
deletedRowKeys.forEach(keyStr => {
// Find original data
const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr);
@@ -1847,8 +1894,16 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}
if (Object.keys(values).length === 0) return;
updates.push({ keys: pkData, values });
const normalizedValues: Record<string, any> = {};
Object.entries(values).forEach(([col, val]) => {
const normalizedVal = normalizeCommitCellValue(col, val, 'update');
if (normalizedVal !== undefined) {
normalizedValues[col] = normalizedVal;
}
});
if (Object.keys(normalizedValues).length === 0) return;
updates.push({ keys: pkData, values: normalizedValues });
});
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {

View File

@@ -501,6 +501,8 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
return fmt.Errorf("connection not open")
}
columnTypeMap := m.loadColumnTypeMap(tableName)
tx, err := m.conn.Begin()
if err != nil {
return err
@@ -513,7 +515,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
var args []interface{}
for k, v := range pk {
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
args = append(args, normalizeMySQLDateTimeValue(v))
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
}
if len(wheres) == 0 {
continue
@@ -535,7 +537,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
for k, v := range update.Values {
sets = append(sets, fmt.Sprintf("`%s` = ?", k))
args = append(args, normalizeMySQLDateTimeValue(v))
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
}
if len(sets) == 0 {
@@ -545,7 +547,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
var wheres []string
for k, v := range update.Keys {
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
args = append(args, normalizeMySQLDateTimeValue(v))
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
}
if len(wheres) == 0 {
@@ -569,12 +571,24 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
var args []interface{}
for k, v := range row {
normalizedValue, omit := normalizeMySQLValueForInsert(k, v, columnTypeMap)
if omit {
continue
}
cols = append(cols, fmt.Sprintf("`%s`", k))
placeholders = append(placeholders, "?")
args = append(args, normalizeMySQLDateTimeValue(v))
args = append(args, normalizedValue)
}
if len(cols) == 0 {
query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName)
res, err := tx.Exec(query)
if err != nil {
return fmt.Errorf("insert error: %v", err)
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return fmt.Errorf("插入未生效:未影响任何行")
}
continue
}
@@ -629,6 +643,69 @@ func normalizeMySQLDateTimeValue(value interface{}) interface{} {
return value
}
func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string {
result := map[string]string{}
table := strings.TrimSpace(tableName)
if table == "" {
return result
}
columns, err := m.GetColumns("", table)
if err != nil {
logger.Warnf("加载列元数据失败(不影响提交):表=%s err=%v", table, err)
return result
}
for _, col := range columns {
name := strings.ToLower(strings.TrimSpace(col.Name))
if name == "" {
continue
}
result[name] = strings.TrimSpace(col.Type)
}
return result
}
func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTypeMap map[string]string) (interface{}, bool) {
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
if !isMySQLTemporalColumnType(columnType) {
return value, false
}
text, ok := value.(string)
if ok && strings.TrimSpace(text) == "" {
// INSERT 空时间字段不写入,交给 DB 默认值处理(如 CURRENT_TIMESTAMP
return nil, true
}
return normalizeMySQLDateTimeValue(value), false
}
func normalizeMySQLValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
if !isMySQLTemporalColumnType(columnType) {
return value
}
text, ok := value.(string)
if ok && strings.TrimSpace(text) == "" {
return nil
}
return normalizeMySQLDateTimeValue(value)
}
func isMySQLTemporalColumnType(columnType string) bool {
raw := strings.ToLower(strings.TrimSpace(columnType))
if raw == "" {
return false
}
if strings.Contains(raw, "datetime") || strings.Contains(raw, "timestamp") {
return true
}
base := raw
if idx := strings.IndexAny(base, "( "); idx >= 0 {
base = base[:idx]
}
return base == "date" || base == "time" || base == "year"
}
func hasTimezoneOffset(text string) bool {
pos := strings.LastIndexAny(text, "+-")
if pos < 0 || pos < 10 || pos+1 >= len(text) {