🐛 fix(mysql): 修复 bit 列写入归一化

Fixes #318
This commit is contained in:
Syngnat
2026-04-11 21:53:52 +08:00
parent aa1bb5b886
commit 89d79ff10c
3 changed files with 264 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
# 2026-04-11 Issue Backlog Tracking
## Scope
- 分支:`codex/issue-242-data-root`
- 策略:按 GitHub issue 创建时间从早到晚逐条处理
- 提交要求:每条 issue 单独本地提交,提交信息使用 `Fixes #<issue>`
## Progress
| Issue | Title | Status | Commit |
| --- | --- | --- | --- |
| #242 | 希望有自定义数据存储位置功能 | Fixed | `42c5500` |
| #287 | 建议补充 Sql Server 数据库图标 | Fixed | `ebae05c` |
| #305 | 金仓数据库设计表新增字段保存失败 | Fixed | `9ecf5be` |
| #306 | 驱动下载 | Fixed | `c49ed95` |
| #308 | clickhouse 获取数据库列表失败 | Fixed | `33bbd91` |
| #310 | 选择库后,右侧行显示各个表 | Fixed | `5bbeba2` |
| #311 | WIN 系统的执行 500 多条 insert 语句要几分钟 | Fixed | `fd7ec11` |
| #315 | 窗体内缩放异常 | Fixed | `e19dd82` |
| #316 | 人大金仓数据库驱动版本过低 | Fixed | `2500183` |
| #317 | 驱动管理增加导入 jar 功能 | Blocked | - |
| #318 | mysql,bit 列,修改成 1 失败 | Fixed | Pending |
## Notes
### #317
- 当前驱动管理只支持内置 Go 驱动和可选 Go 驱动代理包。
- 仓库内不存在 JDBC/JAR 装载、Java 运行时探测、classpath 管理或桥接执行链路。
- 在现有架构下直接增加 “导入 jar” 入口会形成假功能,因此暂记为架构阻塞,不做伪实现。
### #318
- 根因MySQL 写入归一化只覆盖时间列,`bit` 列提交时会把前端传来的 `"1"`/`"0"` 原样透传给驱动。
- 处理:为 MySQL `bit` 列补充写入值归一化,将常见文本/布尔/数值输入转换为驱动可接受的 `[]byte`
- 验证:补充 `internal/db/mysql_value_test.go` 回归测试,覆盖 `bit(1)` 的 insert/update 写入路径。
## Next
- 继续处理下一个最早且可直接落地的开放 issue。

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"math"
"net/url"
"strconv"
"strings"
@@ -731,6 +732,9 @@ func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string {
func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTypeMap map[string]string) (interface{}, bool) {
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
if isMySQLBitColumnType(columnType) {
return normalizeMySQLBitValue(value), false
}
if !isMySQLTemporalColumnType(columnType) {
return normalizeMySQLComplexValue(value), false
}
@@ -744,6 +748,9 @@ func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTy
func normalizeMySQLValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
if isMySQLBitColumnType(columnType) {
return normalizeMySQLBitValue(value)
}
if !isMySQLTemporalColumnType(columnType) {
return value
}
@@ -769,6 +776,154 @@ func isMySQLTemporalColumnType(columnType string) bool {
return base == "date" || base == "time" || base == "year"
}
func isMySQLBitColumnType(columnType string) bool {
raw := strings.ToLower(strings.TrimSpace(columnType))
if raw == "" {
return false
}
base := raw
if idx := strings.IndexAny(base, "( "); idx >= 0 {
base = base[:idx]
}
return base == "bit"
}
func normalizeMySQLBitValue(value interface{}) interface{} {
switch v := value.(type) {
case nil:
return nil
case []byte:
return v
case bool:
if v {
return []byte{1}
}
return []byte{0}
case string:
if bitValue, ok := parseMySQLBitString(v); ok {
return bitValue
}
return value
case int:
if v >= 0 {
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
}
case int8:
if v >= 0 {
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
}
case int16:
if v >= 0 {
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
}
case int32:
if v >= 0 {
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
}
case int64:
if v >= 0 {
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
}
case uint:
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
case uint8:
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
case uint16:
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
case uint32:
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
case uint64:
if bitValue, ok := mysqlBitBytesFromUint64(v); ok {
return bitValue
}
case float32:
if v >= 0 && math.Trunc(float64(v)) == float64(v) {
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
}
case float64:
if v >= 0 && math.Trunc(v) == v {
if bitValue, ok := mysqlBitBytesFromUint64(uint64(v)); ok {
return bitValue
}
}
}
return value
}
func parseMySQLBitString(text string) ([]byte, bool) {
raw := strings.TrimSpace(text)
if raw == "" {
return nil, false
}
switch strings.ToLower(raw) {
case "true":
return []byte{1}, true
case "false":
return []byte{0}, true
}
if len(raw) > 3 && (raw[0] == 'b' || raw[0] == 'B') && raw[1] == '\'' && raw[len(raw)-1] == '\'' {
value, err := strconv.ParseUint(raw[2:len(raw)-1], 2, 64)
if err == nil {
return mysqlBitBytesFromUint64OrZero(value), true
}
return nil, false
}
if len(raw) > 2 && (strings.HasPrefix(raw, "0b") || strings.HasPrefix(raw, "0B")) {
value, err := strconv.ParseUint(raw[2:], 2, 64)
if err == nil {
return mysqlBitBytesFromUint64OrZero(value), true
}
return nil, false
}
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return nil, false
}
return mysqlBitBytesFromUint64OrZero(value), true
}
func mysqlBitBytesFromUint64(value uint64) ([]byte, bool) {
return mysqlBitBytesFromUint64OrZero(value), true
}
func mysqlBitBytesFromUint64OrZero(value uint64) []byte {
if value == 0 {
return []byte{0}
}
var buf [8]byte
index := len(buf)
for value > 0 {
index--
buf[index] = byte(value)
value >>= 8
}
return append([]byte(nil), buf[index:]...)
}
func hasTimezoneOffset(text string) bool {
pos := strings.LastIndexAny(text, "+-")
if pos < 0 || pos < 10 || pos+1 >= len(text) {

View File

@@ -0,0 +1,68 @@
package db
import (
"bytes"
"testing"
)
func TestNormalizeMySQLValueForWrite_ConvertsBitTextToBytes(t *testing.T) {
t.Parallel()
columnTypes := map[string]string{"enabled": "bit(1)"}
cases := []struct {
name string
value interface{}
want []byte
}{
{name: "string one", value: "1", want: []byte{1}},
{name: "string zero", value: "0", want: []byte{0}},
{name: "bool true", value: true, want: []byte{1}},
{name: "bool false", value: false, want: []byte{0}},
{name: "float integral", value: float64(1), want: []byte{1}},
{name: "binary literal", value: "b'1'", want: []byte{1}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := normalizeMySQLValueForWrite("enabled", tc.value, columnTypes)
gotBytes, ok := got.([]byte)
if !ok {
t.Fatalf("期望 bit 写入值被转换为 []byte实际=%T(%v)", got, got)
}
if !bytes.Equal(gotBytes, tc.want) {
t.Fatalf("bit 写入值不符合预期want=%v got=%v", tc.want, gotBytes)
}
})
}
}
func TestNormalizeMySQLValueForInsert_ConvertsBitTextToBytes(t *testing.T) {
t.Parallel()
columnTypes := map[string]string{"enabled": "bit(1)"}
got, omit := normalizeMySQLValueForInsert("enabled", "1", columnTypes)
if omit {
t.Fatalf("bit(1) 插入值不应被省略")
}
gotBytes, ok := got.([]byte)
if !ok {
t.Fatalf("期望 bit 插入值被转换为 []byte实际=%T(%v)", got, got)
}
if !bytes.Equal(gotBytes, []byte{1}) {
t.Fatalf("bit 插入值不符合预期want=%v got=%v", []byte{1}, gotBytes)
}
}
func TestNormalizeMySQLValueForWrite_KeepsNonBitTextUntouched(t *testing.T) {
t.Parallel()
columnTypes := map[string]string{"name": "varchar(255)"}
got := normalizeMySQLValueForWrite("name", "1", columnTypes)
if text, ok := got.(string); !ok || text != "1" {
t.Fatalf("非 bit 列不应被转换,实际=%T(%v)", got, got)
}
}