소스 검색

feat(migrate): 时间列统一为 FieldType_DateTime 毫秒 — 与 gormvrtx 对齐

migrate 此前把 DATE/DATETIME/TIMESTAMP 建成 Uint64 存 Unix 纳秒, 与
gormvrtx 的 DateTime 毫秒矛盾: 迁过来的表 GORM 读 created_at 直接类型
错误 (实测报告)。四处配套修改:
- sqltype.go: infer → FieldType_DateTime; sqlTypeName 兜底 DATETIME
  (不再退化 BLOB); 导出声明 DATETIME(6)/TIMESTAMP(6) 毫秒基准。
- value.go: 写 (SQL→引擎) time.Time 直传/文本 UTC 解析/整数按毫秒,
  零时间可空列→NULL 对齐 gormvrtx; 读 (引擎→SQL) DateTime 毫秒→
  时间文本 (.123 毫秒无损); 旧 Uint64+TimeKind 纳秒盘面保留兼容
  (判别依据列 FieldType, 双路并存)。
- types.go/sql.go: 注释与 TimeKind 文档同步。
测试: 全量往返绿 + 引擎 meta 断言 FieldType_DateTime + 旧纳秒表兼容用例。
yl32940 3 주 전
부모
커밋
c24a0afb08
7개의 변경된 파일과 453개의 추가작업 그리고 59개의 파일을 삭제
  1. 169 14
      migrate/roundtrip_test.go
  2. 3 2
      migrate/sql.go
  3. 22 7
      migrate/sqltype.go
  4. 6 4
      migrate/sqltype_test.go
  5. 16 9
      migrate/types.go
  6. 122 22
      migrate/value.go
  7. 115 1
      migrate/value_test.go

+ 169 - 14
migrate/roundtrip_test.go

@@ -449,12 +449,13 @@ func TestSQLiteOriginToVRTX(t *testing.T) {
 //
 // 三条硬要求, 每一条都对应一个真实踩过的坑:
 //
-//  1. 落 uint64 —— VRTX 没有日期类型, 时间就是 8 字节整数 (时序键 _ts 同款),
-//     只有这样时间列才能比较大小、做范围查询;
+//  1. 落引擎的专用时间类型 FieldType_DateTime (行内 8 字节 Unix 毫秒, 与 gormvrtx /
+//     SQL driver 同一约定) —— 迁过来的表经 ORM 读 created_at 时列类型才是 datetime
+//     (扫描类型 time.Time), 不会出现"列是 uint64、却要按时间读"的类型错误;
 //  2. 建在时间列上的索引必须真的建出来。时间列以前落在 dynamic 上, 引擎会拒
 //     "动态列不能作为索引键", 整张表跟着废掉 (resource_online.idx_ctime 那个报错);
-//  3. 值要是时间戳本身, 且按 UTC 解释 —— 同一份数据在不同时区的机器上必须迁出
-//     同一个数。
+//  3. 值要是时间戳本身 (毫秒), 且按 UTC 解释 —— 同一份数据在不同时区的机器上
+//     必须迁出同一个数。
 func TestSQLiteTimeColumnsToVRTX(t *testing.T) {
 	dir := t.TempDir()
 	srcPath := filepath.Join(dir, "src.sqlite")
@@ -471,7 +472,7 @@ func TestSQLiteTimeColumnsToVRTX(t *testing.T) {
 			day DATE NOT NULL
 		)`,
 		`CREATE INDEX idx_Events_ctime ON Events(ctime)`,
-		`INSERT INTO Events (id, ctime, day) VALUES (1, '2024-03-05 12:34:56', '2024-03-05')`,
+		`INSERT INTO Events (id, ctime, day) VALUES (1, '2024-03-05 12:34:56.123', '2024-03-05')`,
 	} {
 		if _, err := db.Exec(s); err != nil {
 			t.Fatalf("执行 %s: %v", s, err)
@@ -499,30 +500,184 @@ func TestSQLiteTimeColumnsToVRTX(t *testing.T) {
 
 	snap := snapshotVRTX(t, midPath, "Events")
 
-	// 1. 时间列是 uint64, 不是 dynamic
+	// 1. 时间列是引擎专用 datetime 类型, 不是 dynamic / 普通 uint64
 	cols := strings.Join(snap.columns, ",")
-	for _, want := range []string{"ctime:uint64", "day:uint64"} {
+	for _, want := range []string{"ctime:datetime", "day:datetime"} {
 		if !strings.Contains(cols, want) {
 			t.Errorf("时间列应当落成 %s, 实际列结构 %v", want, snap.columns)
 		}
 	}
 
+	// 1b. 直接查引擎 meta: 列的 FieldType 必须是 FieldType_DateTime —— gormvrtx 的
+	// 扫描类型映射 (scanTypeOf) 正是按这个值给出 time.Time, 这是"ORM 读 created_at
+	// 不报类型错误"的确切依据, 不靠 migrate 自己的读端自证。
+	vdb, err := core.Open(midPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	vtbl, ok := vdb.FindTable("Events")
+	if !ok {
+		t.Fatal("VRTX 里没有 Events 表")
+	}
+	wantFT := map[string]types.FieldType{"ctime": types.FieldType_DateTime, "day": types.FieldType_DateTime}
+	for _, f := range vtbl.GetFields() {
+		name := util.BytesToString(f.FieldName)
+		if want, ok := wantFT[name]; ok && f.FieldType != want {
+			t.Errorf("列 %s 的 FieldType = %v, 期望 %v (gormvrtx 会因此给错扫描类型)", name, f.FieldType, want)
+		}
+		delete(wantFT, name)
+	}
+	if len(wantFT) != 0 {
+		t.Errorf("Events 表缺列: %v", wantFT)
+	}
+	_ = vdb.Close()
+
 	// 2. 索引建出来了 (以前这里整张表都废掉)
 	if got := snap.indexes["idx_Events_ctime"]; got != "btree(ctime)" {
 		t.Errorf("建在时间列上的索引没建出来, 实际 %v", snap.indexes)
 	}
 
-	// 3. 值是 UTC 时间戳本身。数字写死是为了不让实现自证自己:
-	// 2024-03-05 12:34:56 UTC = 1709642096 秒; 2024-03-05 = 1709596800 秒。
-	for col, wantNS := range map[string]uint64{
-		"ctime": 1709642096000000000,
-		"day":   1709596800000000000,
+	// 3. 值是 UTC 毫秒时间戳本身。数字写死是为了不让实现自证自己:
+	// 2024-03-05 12:34:56.123 UTC = 1709642096123 毫秒; 2024-03-05 = 1709596800000 毫秒。
+	for col, wantMS := range map[string]uint64{
+		"ctime": 1709642096123,
+		"day":   1709596800000,
 	} {
 		got := snap.rows[1][col]
-		if s := fmt.Sprintf("%v", got); s != strconv.FormatUint(wantNS, 10) {
-			t.Errorf("%s 的 Unix 纳秒应为 %d, 实际 %v (%T)", col, wantNS, got, got)
+		if s := fmt.Sprintf("%v", got); s != strconv.FormatUint(wantMS, 10) {
+			t.Errorf("%s 的 Unix 毫秒应为 %d, 实际 %v (%T)", col, wantMS, got, got)
+		}
+	}
+
+	// 4. 再迁回 SQLite, 时间文本必须逐字符回来 (毫秒无损)。
+	backPath := filepath.Join(dir, "back.sqlite")
+	v2, err := OpenVRTX(midPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	s2, err := OpenSQLite(backPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := Run(v2, s2, quiet()); err != nil {
+		t.Fatalf("VRTX→SQLite: %v", err)
+	}
+	_ = v2.Close()
+	_ = s2.Close()
+
+	bdb, err := sql.Open("sqlite", backPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var ctime, day string
+	// CAST 成 TEXT 读原样存储值: modernc 驱动会把 DATETIME 声明列里的时间文本
+	// 读成 time.Time, 再 Scan 进 string 时又格式化回 RFC3339 —— 那是驱动的往返
+	// 转换, 不是迁移写错了文本。这里要验的是"落库的文本逐字符无损"。
+	if err := bdb.QueryRow(`SELECT CAST(ctime AS TEXT), CAST(day AS TEXT) FROM Events WHERE id = 1`).
+		Scan(&ctime, &day); err != nil {
+		t.Fatalf("读回 SQLite: %v", err)
+	}
+	_ = bdb.Close()
+	if ctime != "2024-03-05 12:34:56.123" {
+		t.Errorf("ctime 应逐字符回来, 实际 %q", ctime)
+	}
+	// day 走到 VRTX 再直出, DATE 的细分声明丢失 (VRTX schema 没有放 TimeKind 的位置,
+	// 导出只能按 DATETIME 兜底声明, 见 types.go 的 TimeKind 注释) —— 但值必须无损:
+	// 仍是那一天的零点, 不是日期被砍或时间被挪。
+	if day != "2024-03-05 00:00:00" {
+		t.Errorf("day 的值应无损回来 (DATETIME 兜底声明), 实际 %q", day)
+	}
+}
+
+// TestLegacyUint64NanoTimeColumns 兼容路径: 存量旧表 (Uint64 列存 Unix 纳秒) 经 migrate
+// 读出仍按【纳秒】解释, 不能被新逻辑当成毫秒。
+//
+// 场景还原: 更早版本的 migrate 把 SQL 时间列迁成 Uint64 存纳秒, 并在 __vrtx_meta 里
+// 给该列打上 TimeKind 标记。这样的中间库今天再迁, 结构按 meta 还原成 Uint64+TimeKind
+// (写路径照旧换算成纳秒), 值必须是原来的那个纳秒数 —— 走错路会静默给出 1970 年附近
+// 的垃圾时间 (毫秒当纳秒解释) 或天文数字 (纳秒当毫秒解释)。
+func TestLegacyUint64NanoTimeColumns(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	dstPath := filepath.Join(dir, "dst.db")
+
+	db, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, s := range []string{
+		`CREATE TABLE Events (
+			id INTEGER PRIMARY KEY,
+			ctime DATETIME NOT NULL
+		)`,
+		`INSERT INTO Events (id, ctime) VALUES (1, '2024-03-05 12:34:56.123456')`,
+		// 旧版 migrate 写出的保真表: ctime 的类型记成 Uint64 (10) + TimeKind 标记。
+		// 这正是存量中间库的样子 —— 新实现读到它, 列必须还原成 Uint64+TimeKind。
+		`CREATE TABLE __vrtx_meta (tbl TEXT PRIMARY KEY, payload TEXT NOT NULL)`,
+		`INSERT INTO __vrtx_meta (tbl, payload) VALUES ('Events',
+			'{"v":1,"pk":"id","pkt":2,"cols":[{"n":"id","t":11},{"n":"ctime","t":10,"tm":"datetime"}]}')`,
+	} {
+		if _, err := db.Exec(s); err != nil {
+			t.Fatalf("执行 %s: %v", s, err)
+		}
+	}
+	_ = db.Close()
+
+	// 结构按旧 meta 还原: ctime 是 Uint64 + TimeKind, 不是 DateTime。
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	sc, err := src.Schema("Events")
+	if err != nil {
+		t.Fatalf("读结构: %v", err)
+	}
+	var ctimeCol *Column
+	for i := range sc.Columns {
+		if sc.Columns[i].Name == "ctime" {
+			ctimeCol = &sc.Columns[i]
 		}
 	}
+	if ctimeCol == nil {
+		t.Fatal("Events 表没有 ctime 列")
+	}
+	if ctimeCol.Type != types.FieldType_Uint64 || ctimeCol.TimeKind != "datetime" {
+		t.Fatalf("旧 meta 应把 ctime 还原成 Uint64+TimeKind, 实际 %v (tm=%q)", ctimeCol.Type, ctimeCol.TimeKind)
+	}
+
+	dst, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := Run(src, dst, quiet()); err != nil {
+		t.Fatalf("SQLite→VRTX: %v", err)
+	}
+	_ = src.Close()
+	_ = dst.Close()
+
+	// 写路径按旧盘面换算: 时间文本 → Unix 纳秒, 落进 Uint64 列。
+	// 1709642096123456000 纳秒 = 2024-03-05 12:34:56.123456 UTC。
+	snap := snapshotVRTX(t, dstPath, "Events")
+	if got := fmt.Sprintf("%v", snap.rows[1]["ctime"]); got != "1709642096123456000" {
+		t.Errorf("旧 Uint64 时间列应按纳秒落值, 实际 %v (%T)",
+			snap.rows[1]["ctime"], snap.rows[1]["ctime"])
+	}
+	cols := strings.Join(snap.columns, ",")
+	if !strings.Contains(cols, "ctime:uint64") {
+		t.Errorf("按旧 meta 建的列应是 uint64, 实际列结构 %v", snap.columns)
+	}
+
+	// 读路径 (SQL 侧) 同样按纳秒还原: 从这张旧 VRTX 表经 SQL 导出到带旧 meta 的库,
+	// 时间文本必须原样回来。这里直接钉 coerceForSQL 的 Uint64+TimeKind 分支:
+	// 纳秒进、微秒文本出 (MySQL DATETIME(6) 的上限), 日期语义不丢。
+	legacyCol := Column{Name: "ctime", Type: types.FieldType_Uint64, TimeKind: "datetime"}
+	got, err := coerceForSQL(legacyCol, snap.rows[1]["ctime"], false)
+	if err != nil {
+		t.Fatalf("旧列导出 SQL 不该报错: %v", err)
+	}
+	if s, ok := got.(string); !ok || s != "2024-03-05 12:34:56.123456" {
+		t.Errorf("旧 Uint64 时间列应按纳秒还原成时间文本, 实际 %v (%T)", got, got)
+	}
 }
 
 // TestSkippedIndexDoesNotKillTable 目标端建不出来的索引应当只跳它自己。

+ 3 - 2
migrate/sql.go

@@ -490,8 +490,9 @@ type metaCol struct {
 	// 带着小数位, 但 SQLite 的 TEXT 列看不出来 —— 存进保真表才能原样还原。
 	Param byte `json:"p,omitempty"`
 	// Time 源 SQL 的时间语义 ("datetime" / "date" / "timestamp"), 空即普通列。
-	// 盘上它就是 uint64 (Unix 纳秒), 靠这个标记导回 SQL 时才还原成 DATETIME ——
-	// 光看 FieldType 分不出"时间戳"和"恰好取值很大的整数"。
+	// 新迁的表盘上它是 FieldType_DateTime (Unix 毫秒), 靠这个标记导回 SQL 时才还原成
+	// 当初的 DATETIME / DATE / TIMESTAMP —— 光看 FieldType 只能分出"是时间"。
+	// 旧库可能是 Uint64+纳秒 (更早的实现), 读路径按列的 FieldType 分岔兼容, 两条路都在。
 	Time string `json:"tm,omitempty"`
 	// Null 该列可空。SQL 侧的可空性虽然能从 PRAGMA / information_schema 读出来,
 	// 但那是【建表时写下的声明】; 我们导出时按 VRTX 的 NULL 位图写 NULL / NOT NULL,

+ 22 - 7
migrate/sqltype.go

@@ -24,7 +24,7 @@ import (
 //	CHAR(n) / VARCHAR(n)       按字节容量取最近的宽度档 (16..2048, 超过则 dynamic)
 //	TEXT / JSON                dynamic   (字节原样存, 不解释内容)
 //	BLOB / BINARY              blob      (同为动态列, 但语义是"文件/二进制大对象")
-//	DATE / DATETIME / TIMESTAMP uint64    (Unix 纳秒时间戳, 见 inferSQLType 的说明)
+//	DATE / DATETIME / TIMESTAMP datetime  (引擎专用时间类型, Unix 毫秒, 见 inferSQLType 的说明)
 //
 // 有符号与无符号落在不同档位不是洁癖: 无符号档 (Uint8..Uint64) 盘上存的就是补码原样,
 // 有符号档 (Int8..Int64) 存的是保序编码 —— 同一个值在两种档位下盘上字节不同, 混用会让
@@ -235,15 +235,20 @@ func inferSQLType(decl string, mysql bool) (types.FieldType, byte) {
 		// 所以声明了长度的 BINARY / VARBINARY 也能原样兑现 —— 它们不再受单堆页上限约束。
 		return types.FieldType_Blob, 0
 	case "DATE", "DATETIME", "TIMESTAMP":
-		// VRTX 没有日期类型: 时间就用 8 字节整数表达 —— 时序索引的键 (_ts) 正是这么
-		// 存的 (见 types.FieldType_Uint64 的说明), 时间范围查询/分段裁剪都按它比较。
-		// 所以 SQL 的时间列落 uint64: 能比较大小、能建索引、能当时序键。值在
-		// value.go 里换算成 Unix 纳秒。
+		// 落引擎的专用时间类型 FieldType_DateTime: 行内 8 字节 Unix 毫秒 (物理编码
+		// 与 uint64 相同, 字节序即时间序), 与 gormvrtx / SQL driver 的约定一致 ——
+		// 迁过来的表经 ORM 读 created_at 时, 列类型 (datetime → time.Time) 才不会
+		// 与取值语义打架。时间比较、范围查询、建索引照常可用。
 		//
 		// 这里以前落在 dynamic, 后果是时间列在 VRTX 里成了"一堆字节": 不能建索引
 		// (引擎直接拒: 动态列不能作为索引键), 也不能做时间范围查询 —— 在一个
 		// 时序数据库里, 这等于把时间列废掉。列名再叫 ctime 也救不回来。
-		return types.FieldType_Uint64, 0
+		//
+		// 中间还有一版落 uint64 存 Unix【纳秒】: 与引擎 DateTime 的毫秒语义矛盾,
+		// 已废弃。存量那些 Uint64+纳秒的表 (meta 里带 TimeKind 标记的 Uint64 列)
+		// 由读路径按列的 FieldType 分岔兼容 (DateTime → 毫秒, Uint64+TimeKind → 纳秒,
+		// 见 value.go), 两条路都在。
+		return types.FieldType_DateTime, 0
 	case "TIME", "INTERVAL", "JSON", "JSONB", "XML", "ENUM", "SET",
 		"BIT", "GEOMETRY", "POINT":
 		// 这些在 VRTX 里没有等价物, 按字节原样存 (读出来就是它本来的文本/编码),
@@ -318,6 +323,15 @@ func sqlTypeName(t types.FieldType, param byte, mysql bool) string {
 			return "BIGINT UNSIGNED"
 		}
 		return "INTEGER"
+	case types.FieldType_DateTime:
+		// 引擎 DateTime 列还原成 SQL 时间列。走到这里说明没带 TimeKind (VRTX 直出,
+		// schema 里没有它的存放位置): DATE / TIMESTAMP 当初的细分已不可考, 一律按
+		// DATETIME 声明 —— 比在 INTEGER/BLOB 上退化成普通列保守。带 TimeKind 的列
+		// 由 columnTypeName 先一步拐进 sqlTimeName, 到不了这里。
+		if mysql {
+			return "DATETIME(6)"
+		}
+		return "DATETIME"
 	case types.FieldType_Int8, types.FieldType_Int16, types.FieldType_Int32, types.FieldType_Int64:
 		// MySQL 侧要落到有符号列, 才与 VRTX 的有符号档语义对齐。
 		// SQLite 只有 INTEGER (有符号 64 位), 窄档声明回去会被读成 Int64 ——
@@ -419,7 +433,8 @@ func sqlTimeKind(decl string) string {
 // sqlTimeName 时间语义 → SQL 列声明。
 //
 // MySQL 侧带上 (6): 原列若不是 DATETIME(0) 而是带小数秒的, 声明成 DATETIME(0)
-// 会把微秒截掉 —— 时间戳本身是纳秒精度, 声明不能比它窄。
+// 会把毫秒截掉 —— DateTime 列的值是毫秒精度, 声明不能比它窄 (DATETIME(6) 只宽不损,
+// 毫秒原样装得下)。
 func sqlTimeName(kind string, mysql bool) string {
 	switch kind {
 	case "date":

+ 6 - 4
migrate/sqltype_test.go

@@ -82,9 +82,10 @@ func TestInferSQLType(t *testing.T) {
 		// 只有超过 MaxInlineColumnWidth 才落变长列
 		{"VARCHAR(4000)", true, types.FieldType_Dynamicbyte, 0},
 
-		// 时间列落 uint64 (Unix 纳秒), 不是动态列
-		{"DATETIME", true, types.FieldType_Uint64, 0},
-		{"DATE", false, types.FieldType_Uint64, 0},
+		// 时间列落引擎的专用 datetime 类型 (Unix 毫秒), 不是动态列也不是普通整数
+		{"DATETIME", true, types.FieldType_DateTime, 0},
+		{"DATE", false, types.FieldType_DateTime, 0},
+		{"TIMESTAMP", true, types.FieldType_DateTime, 0},
 	}
 	for _, c := range cases {
 		ft, param := inferSQLType(c.decl, c.mysql)
@@ -132,7 +133,7 @@ func TestMySQLColumnDecl(t *testing.T) {
 		{"decimal(18,2)", "decimal", 20, 18, 2, types.FieldType_Decimal, 2},
 		// 长度信息为 0 (数值/时间列没有 octet length) 时退回纯类型名
 		{"int", "int", 0, 10, 0, types.FieldType_Int32, 0},
-		{"datetime", "datetime", 0, 0, 0, types.FieldType_Uint64, 0},
+		{"datetime", "datetime", 0, 0, 0, types.FieldType_DateTime, 0},
 	}
 	for _, c := range cases {
 		decl := mysqlColumnDecl(c.dataType, false, c.charBytes, c.numPrec, c.numScale)
@@ -267,6 +268,7 @@ func TestSQLTypeRoundTrip(t *testing.T) {
 			{types.FieldType_Year, 0},
 			{types.FieldType_Float64, 0},
 			{types.FieldType_Int64, 0},
+			{types.FieldType_DateTime, 0}, // 无 TimeKind 时按 DATETIME 声明导出再读回
 		}
 		if mysql {
 			exact = append(exact,

+ 16 - 9
migrate/types.go

@@ -51,10 +51,14 @@ type Column struct {
 	Param byte
 	// TimeKind 这列在源 SQL 里的时间语义: "datetime" / "date" / "timestamp", 空即普通列。
 	//
-	// 为什么要单独记: VRTX 没有日期类型, 时间统一用 8 字节整数表达 (Unix 纳秒时间戳,
-	// 时序键 _ts 就是这么存的), 所以 SQL 的 DATETIME 落到 VRTX 就是一列 uint64 ——
-	// 光看 FieldType 分不出它是"时间戳"还是"恰好取值很大的整数"。带着这个标记,
-	// 导回 SQL 时才能还原成 DATETIME 而不是 BIGINT。
+	// 为什么还要单独记: 时间列在 VRTX 侧落成引擎的专用类型 FieldType_DateTime
+	// (行内 8 字节 Unix 毫秒, 与 gormvrtx / SQL driver 同一约定), 盘上只能看出
+	// "这是时间", 分不出当初声明的是 DATETIME / DATE 还是 TIMESTAMP —— 带着这个
+	// 标记, 导回 SQL 时才能还原成原来的声明, 而不是一律 DATETIME。
+	//
+	// 它还管着【旧库兼容】的半条命: 更早的实现把 SQL 时间列迁成 Uint64 存 Unix
+	// 纳秒, 那些存量表的 meta 里同样打着这个标记。读路径按列的 FieldType 分岔
+	// (DateTime → 毫秒, Uint64+TimeKind → 旧纳秒数据), 两条路都在 (见 value.go)。
 	//
 	// 标记放在哪, 以及它管不到哪一段: 目前只写进 SQL 侧的保真表 __vrtx_meta,
 	// VRTX 的 schema 里【没有地方放它】——
@@ -65,13 +69,14 @@ type Column struct {
 	//     带列级参数 ("uint64 的 Param 恒为 0"), 改这条就是把一个格式不变量拆了。
 	//
 	// 所以: SQL → VRTX → SQL 能还原成时间列 (标记跟着 __vrtx_meta 往返),
-	// 但【从 VRTX 直接导出】拿不到标记 —— 时间列会被声明成 BIGINT 而不是 DATETIME。
-	// 要补上这一段, 得给 VRTX 侧一个正经的存放位置 (加 FieldType / 内部元数据表),
+	// 但【从 VRTX 直接导出】拿不到标记 —— DateTime 列会被声明成 DATETIME(6)
+	// (sqlTypeName 的兜底还原), DATE / TIMESTAMP 当初的细分丢失。要补上这一段,
+	// 得给 VRTX 侧一个正经的存放位置 (加 FieldType / 内部元数据表),
 	// 那是一次格式版本决策, 不适合顺手做。
 	//
-	// 另外, 这个"退化成 BIGINT"其实比强转 DATETIME 更保守: uint64 里存的是纳秒,
-	// 而 formatSQLTime 只能写到微秒 (MySQL DATETIME(6) 的上限), 转成时间文本会丢掉
-	// 后三位 —— 类型还原和精度无损目前只能二选一。
+	// 精度说明: DateTime 列是毫秒精度, formatSQLTime 恰好写到毫秒, 转时间文本
+	// 无损; 只有存量 Uint64+纳秒的旧表会按微秒截断导出 (MySQL DATETIME(6) 的上限)
+	// —— 那是旧盘面自身的历史包袱, 不是现在的损失。
 	TimeKind string
 	// Nullable 该列允许 NULL (VRTX 的 FlagNullable ⇄ SQL 的可空声明)。
 	//
@@ -153,6 +158,8 @@ func TypeName(t types.FieldType) string {
 		return "float32"
 	case types.FieldType_Uint64:
 		return "uint64"
+	case types.FieldType_DateTime:
+		return "datetime"
 	case types.FieldType_Int64:
 		return "int64"
 	case types.FieldType_Float64:

+ 122 - 22
migrate/value.go

@@ -34,6 +34,9 @@ func zeroForVRTX(ft types.FieldType) any {
 		return int32(0)
 	case types.FieldType_Uint64:
 		return uint64(0)
+	case types.FieldType_DateTime:
+		// 引擎 DateTime 列的零值: 零毫秒 (Unix 0)。非空列上的"没有时间"就落它。
+		return uint64(0)
 	case types.FieldType_Int8:
 		return int8(0)
 	case types.FieldType_Int16:
@@ -126,6 +129,11 @@ func coerceForVRTX(ft types.FieldType, param byte, v any, col string, strict boo
 		}
 		return int32(uint32(n)), nil
 
+	case types.FieldType_DateTime:
+		// 引擎专用时间列 (Unix 毫秒): time.Time 直传、文本解析、整数按毫秒 ——
+		// 换算约定见 coerceDateTimeForVRTX。
+		return coerceDateTimeForVRTX(v, col, nullable)
+
 	case types.FieldType_Uint64:
 		// 有符号来源按"64 位位模式"原样搬: int64(-1) → uint64(0xFFFF...)。
 		// 导出回 SQL 时再用 int64(u) 写, 于是 MySQL 的 -1 ↔ VRTX 的 0xFFFF...
@@ -358,7 +366,8 @@ func parseInt128(s string) (*big.Int, error) {
 //   - 字节类列 (定长串 / 变长) 若是合法 UTF-8 就绑成 string, 否则绑 []byte。
 //     前者让 SQLite 把它存成 TEXT (BLOB 亲和性的列不会自动转换),
 //     后者保证二进制列不被改坏。定长列导出时去掉尾部补零。
-//   - 时间列 (TimeKind 非空) 一律还原成时间文本, 不写纳秒整数。
+//   - 时间列一律还原成时间文本, 不写整数: FieldType_DateTime 列按【毫秒】解释,
+//     存量 Uint64+TimeKind 旧表按【纳秒】解释 (两条路的判别就是列的 FieldType)。
 //
 // mysql 来自目标方言 (Sink), 与 sqlTypeName 的第二个参数同源。
 func coerceForSQL(c Column, v any, mysql bool) (any, error) {
@@ -409,15 +418,33 @@ func coerceForSQL(c Column, v any, mysql bool) (any, error) {
 		// 同 uint16: 给无符号数值, 别把 4294967295 当成 -1 送给 MySQL 的 INT UNSIGNED。
 		return int64(uint32(n)), nil
 
+	case types.FieldType_DateTime:
+		// 时间列: 盘上 (引擎 DecodeValue) 是 uint64 毫秒, 回 SQL 得还原成时间文本 ——
+		// 否则 MySQL 的 DATETIME 列会收到一个 1.7e12 的整数 (驱动不会替我们转)。
+		// time.Time 先按绝对时间取毫秒, 其余形态按数值 (毫秒) 收。
+		var ms int64
+		switch x := v.(type) {
+		case time.Time:
+			ms = x.UnixMilli()
+		default:
+			u, err := asUint64(v)
+			if err != nil {
+				return nil, fmt.Errorf("列 %s: %w", c.Name, err)
+			}
+			ms = int64(u)
+		}
+		return formatSQLTime(ms, c.TimeKind), nil
+
 	case types.FieldType_Uint64:
 		u, err := asUint64(v)
 		if err != nil {
 			return nil, fmt.Errorf("列 %s: %w", c.Name, err)
 		}
 		if c.IsTime() {
-			// 时间列: 盘上是 Unix 纳秒, 回 SQL 得还原成时间文本 —— 否则 MySQL 的
-			// DATETIME 列会收到一个 1.7e18 的整数 (驱动不会替我们转)。
-			return formatSQLTime(int64(u), c.TimeKind), nil
+			// 存量旧表: 这列是当年迁成 uint64 存 Unix【纳秒】的时间列 (meta 里带
+			// TimeKind 标记), 回 SQL 仍按纳秒还原成时间文本。新表是 FieldType_DateTime,
+			// 走上面那条毫秒路 —— 两条路并存, 判别依据就是列的 FieldType。
+			return formatSQLTimeNS(int64(u), c.TimeKind), nil
 		}
 		// 绑定的 Go 类型必须跟目标列的声明一致, 而两个方言的声明不同:
 		//   - MySQL: sqlTypeName 声明成 BIGINT UNSIGNED (uint64 是无符号档),
@@ -550,6 +577,9 @@ func zeroForSQL(c Column) any {
 	case types.FieldType_Uint8, types.FieldType_Uint16, types.FieldType_Uint32, types.FieldType_Uint64,
 		types.FieldType_Int8, types.FieldType_Int16, types.FieldType_Int32, types.FieldType_Int64:
 		return int64(0)
+	case types.FieldType_DateTime:
+		// 与 coerceForSQL 的 nil 分支同一约定: 零时间给 Unix 0 (1970-01-01), 不给数字 0。
+		return formatSQLTime(0, "datetime")
 	default:
 		return ""
 	}
@@ -680,8 +710,14 @@ func asUint64(v any) (uint64, error) {
 
 // ============================ 时间列 ============================
 //
-// VRTX 的时间表示只有一种: 8 字节整数 (Unix 纳秒时间戳)。SQL 的时间列迁进来
-// 要换算成它, 迁回去要换算回时间文本 —— 这一节就是这两个方向。
+// 时间列在 VRTX 侧有两种盘面, 读路径按【列的 FieldType】分岔:
+//
+//   - FieldType_DateTime (新): 引擎专用时间类型, 行内 8 字节 Unix【毫秒】;
+//   - FieldType_Uint64 + TimeKind 标记 (旧): 更早的实现把时间列迁成 uint64 存
+//     Unix【纳秒】, 存量表的数据就是这个盘面 —— 判别靠 TimeKind (新表是 DateTime,
+//     旧表才会出现 Uint64 列带着它)。
+//
+// SQL 时间列迁进来要换算成目标盘面, 迁出去要换算回时间文本 —— 这一节就是这两个方向。
 
 // sqlTimeLayouts SQL 时间文本的常见写法。MySQL 的 DATETIME / TIMESTAMP 打出来
 // 就是第一个; SQLite 存的是 TEXT, 可能带 T 和时区; DATE 只有日期部分。
@@ -693,32 +729,33 @@ var sqlTimeLayouts = []string{
 	"2006-01-02",
 }
 
-// parseSQLTime 把 SQL 时间文本解析成 Unix 纳秒。
+// parseSQLTime 把 SQL 时间文本解析成 time.Time。
 //
 // 一律按 UTC 解释 (除非文本自带偏移): MySQL 的 DATETIME 本身不带时区, 若用本地
 // 时区去解释, 同一份数据在不同机器上会迁出不同的时间戳 —— 迁移工具不该引入
 // 这种漂移。往返一致性优先于"看着像本地时间"。
-func parseSQLTime(s string) (int64, bool) {
+func parseSQLTime(s string) (time.Time, bool) {
 	for _, layout := range sqlTimeLayouts {
 		if tm, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
-			return tm.UnixNano(), true
+			return tm, true
 		}
 	}
-	return 0, false
+	return time.Time{}, false
 }
 
-// parseIntOrTime 先按整数读, 读不动再按时间文本读。
+// parseIntOrTime 先按整数读, 读不动再按时间文本读 (时间文本 → Unix 纳秒)。
 //
-// 时间列映射成 uint64 后, 值可能是 time.Time (MySQL 驱动开了 parseTime),
-// 也可能是文本 (parseTime=false 的默认档, 以及 SQLite 的 DATETIME 列) ——
-// 后者必须在这里换算, 否则写进 uint64 列时会被当成"不是数字"直接报错。
+// 这是【旧盘面】的入口: 时间列映射成 uint64 后 (存量 Uint64+TimeKind 表), 值可能
+// 是 time.Time (MySQL 驱动开了 parseTime), 也可能是文本 (parseTime=false 的默认档,
+// 以及 SQLite 的 DATETIME 列) —— 后者必须在这里换算, 否则写进 uint64 列时会被
+// 当成"不是数字"直接报错。新的 FieldType_DateTime 列不走这里 (见 coerceDateTimeForVRTX)。
 func parseIntOrTime(s string) (int64, error) {
 	s = strings.TrimSpace(s)
 	if n, err := strconv.ParseInt(s, 10, 64); err == nil {
 		return n, nil
 	}
-	if ns, ok := parseSQLTime(s); ok {
-		return ns, nil
+	if tm, ok := parseSQLTime(s); ok {
+		return tm.UnixNano(), nil
 	}
 	return 0, fmt.Errorf("无法把 %q 当整数 (也不是能识别的时间文本)", s)
 }
@@ -729,17 +766,80 @@ func parseUintOrTime(s string) (uint64, error) {
 	if n, err := strconv.ParseUint(s, 10, 64); err == nil {
 		return n, nil
 	}
-	if ns, ok := parseSQLTime(s); ok {
-		return uint64(ns), nil
+	if tm, ok := parseSQLTime(s); ok {
+		return uint64(tm.UnixNano()), nil
 	}
 	return 0, fmt.Errorf("无法把 %q 当整数 (也不是能识别的时间文本)", s)
 }
 
-// formatSQLTime Unix 纳秒 → SQL 时间文本。
+// coerceDateTimeForVRTX SQL 侧时间值 → FieldType_DateTime 列的形态 (time.Time / 毫秒数)。
+//
+// 引擎 codec 认两种输入: time.Time (编码层自行截毫秒) 与毫秒数值。SQL 侧的驱动值
+// 是 time.Time / 文本 / 整数, 这里统一收编:
+//
+//   - time.Time 原样直传 (与 gormvrtx / SQL driver 的写入路径同一约定);
+//   - 文本按 UTC 解析成 time.Time (parseSQLTime, 不用本地时区 —— 同一批数据在
+//     不同时区的机器上必须迁出同一个时间戳);
+//   - 整数按【毫秒】解释 —— DateTime 列的盘上单位就是毫秒 (Uint64+TimeKind 的
+//     旧库才是纳秒, 那条路在 coerceForSQL 的 Uint64 分支)。
+//
+// 零时间对齐 gormvrtx 语义: 可空列写 NULL (零 time.Time 与 nil 一样表示"没有时间"),
+// 非空列落零毫秒 (Unix 0)。
+func coerceDateTimeForVRTX(v any, col string, nullable bool) (any, error) {
+	switch x := v.(type) {
+	case time.Time:
+		if x.IsZero() {
+			if nullable {
+				return nil, nil
+			}
+			return uint64(0), nil
+		}
+		return x, nil
+	case string:
+		return parseSQLTimeValue(x, col)
+	case []byte:
+		return parseSQLTimeValue(string(x), col)
+	default:
+		n, err := asInt64(v)
+		if err != nil {
+			return nil, fmt.Errorf("列 %s: %w", col, err)
+		}
+		if n < 0 {
+			return nil, fmt.Errorf("列 %s: 时间戳不能为负: %d", col, n)
+		}
+		return uint64(n), nil // 已是毫秒
+	}
+}
+
+// parseSQLTimeValue 文本 → time.Time, 解析失败报出列名。
+func parseSQLTimeValue(s, col string) (any, error) {
+	tm, ok := parseSQLTime(strings.TrimSpace(s))
+	if !ok {
+		return nil, fmt.Errorf("列 %s: 无法把 %q 当时间文本", col, s)
+	}
+	return tm, nil
+}
+
+// formatSQLTime Unix 毫秒 → SQL 时间文本 (FieldType_DateTime 列的读路径)。
+//
+// DATE 只留日期; DATETIME / TIMESTAMP 留到毫秒 —— 这正是 DateTime 列的全部精度,
+// 转文本无损。
+func formatSQLTime(ms int64, kind string) string {
+	tm := time.UnixMilli(ms).UTC()
+	if kind == "date" {
+		return tm.Format("2006-01-02")
+	}
+	if ms%1000 == 0 {
+		return tm.Format("2006-01-02 15:04:05") // 整秒别拖一串 .000
+	}
+	return tm.Format("2006-01-02 15:04:05.000")
+}
+
+// formatSQLTimeNS Unix 纳秒 → SQL 时间文本 (存量 Uint64+TimeKind 旧表的读路径)。
 //
-// DATE 只留日期; DATETIME / TIMESTAMP 留到微秒 (纳秒到微秒是 SQL 时间类型本身
-// 的限制, MySQL 最细 DATETIME(6), 不是工具丢的)。
-func formatSQLTime(ns int64, kind string) string {
+// 只能写到微秒 (MySQL DATETIME(6) 的上限) —— 纳秒进、微秒出是旧盘面自身的精度
+// 上限, 不是工具丢的。新表走 formatSQLTime, 毫秒无损。
+func formatSQLTimeNS(ns int64, kind string) string {
 	tm := time.Unix(0, ns).UTC()
 	if kind == "date" {
 		return tm.Format("2006-01-02")

+ 115 - 1
migrate/value_test.go

@@ -4,6 +4,7 @@ import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
 	"math"
 	"testing"
+	"time"
 )
 
 // TestRoundFloatToIntBoundary 钉住上界的写法。
@@ -82,7 +83,8 @@ func TestCoerceForSQL8ByteByDialect(t *testing.T) {
 		}
 	}
 
-	// 时间列走的还是"还原成时间文本"那条路, 与方言无关 —— 别被上面的方言分支带走。
+	// 存量旧表 (Uint64+TimeKind) 走的还是"按纳秒还原成时间文本"那条路, 与方言无关 ——
+	// 别被上面的方言分支带走, 也别把它跟新 DateTime 列的毫秒路搞混。
 	timeCol := Column{Name: "t", Type: types.FieldType_Uint64, TimeKind: "datetime"}
 	for _, mysql := range []bool{true, false} {
 		v, err := coerceForSQL(timeCol, int64(0), mysql)
@@ -95,6 +97,118 @@ func TestCoerceForSQL8ByteByDialect(t *testing.T) {
 	}
 }
 
+// TestCoerceForSQLTimeTwoPaths 读路径的两条时间路必须按【列的 FieldType】分岔:
+//
+//   - FieldType_DateTime (新表): 值按 Unix【毫秒】解释;
+//   - Uint64+TimeKind (存量旧表): 值按 Unix【纳秒】解释。
+//
+// 同一大小的整数, 两条路解出的时间差了六个数量级 —— 走错路不是报错而是【静默给出
+// 一个完全错误的时间】, 所以判别依据是列的 FieldType, 不能只看 TimeKind。
+func TestCoerceForSQLTimeTwoPaths(t *testing.T) {
+	// 2024-03-05 12:34:56.123 UTC = 1709642096123 毫秒 = 1709642096123000000 纳秒。
+	const ms = int64(1709642096123)
+	const ns = int64(1709642096123000000)
+
+	dtCol := Column{Name: "ctime", Type: types.FieldType_DateTime, TimeKind: "datetime"}
+	got, err := coerceForSQL(dtCol, uint64(ms), false)
+	if err != nil {
+		t.Fatalf("DateTime 列不该报错: %v", err)
+	}
+	if s, ok := got.(string); !ok || s != "2024-03-05 12:34:56.123" {
+		t.Errorf("DateTime 列应按毫秒还原, 实际 %v (%T)", got, got)
+	}
+
+	legacyCol := Column{Name: "ctime", Type: types.FieldType_Uint64, TimeKind: "datetime"}
+	got, err = coerceForSQL(legacyCol, uint64(ns), false)
+	if err != nil {
+		t.Fatalf("旧 Uint64 时间列不该报错: %v", err)
+	}
+	if s, ok := got.(string); !ok || s != "2024-03-05 12:34:56.123000" {
+		t.Errorf("旧 Uint64 时间列应按纳秒还原 (微秒文本), 实际 %v (%T)", got, got)
+	}
+
+	// DATE 语义只留日期部分; time.Time 输入直接取毫秒 (MySQL parseTime 驱动给得出)。
+	dayCol := Column{Name: "day", Type: types.FieldType_DateTime, TimeKind: "date"}
+	got, err = coerceForSQL(dayCol, time.UnixMilli(ms).UTC(), false)
+	if err != nil {
+		t.Fatalf("DATE 列不该报错: %v", err)
+	}
+	if s, ok := got.(string); !ok || s != "2024-03-05" {
+		t.Errorf("DATE 列应只留日期, 实际 %v (%T)", got, got)
+	}
+
+	// 非时间语义的普通整数列 (无 TimeKind) 不受影响, 原样按位模式走。
+	plainCol := Column{Name: "u", Type: types.FieldType_Uint64}
+	got, err = coerceForSQL(plainCol, uint64(ns), false)
+	if err != nil {
+		t.Fatalf("普通 uint64 列不该报错: %v", err)
+	}
+	if got != int64(ns) {
+		t.Errorf("普通 uint64 列应原样绑 int64 位模式, 实际 %v (%T)", got, got)
+	}
+}
+
+// TestCoerceForVRTXDateTime 写路径: SQL 侧时间值收编成 FieldType_DateTime 列认得的形态。
+//
+// 约定与 gormvrtx / SQL driver 对齐: time.Time 直传 (引擎编码层截毫秒), 文本按 UTC
+// 解析, 整数按【毫秒】; 零时间走可空列写 NULL、非空列落零毫秒。
+func TestCoerceForVRTXDateTime(t *testing.T) {
+	tm := time.Date(2024, 3, 5, 12, 34, 56, 123456789, time.UTC)
+
+	// time.Time 原样直传, 由引擎 codec 自行截毫秒。
+	got, err := coerceForVRTX(types.FieldType_DateTime, 0, tm, "ctime", true, false)
+	if err != nil {
+		t.Fatalf("time.Time 不该报错: %v", err)
+	}
+	if x, ok := got.(time.Time); !ok || !x.Equal(tm) {
+		t.Errorf("time.Time 应原样直传, 实际 %v (%T)", got, got)
+	}
+
+	// 文本按 UTC 解析 (不用本地时区)。
+	got, err = coerceForVRTX(types.FieldType_DateTime, 0, "2024-03-05 12:34:56.123", "ctime", true, false)
+	if err != nil {
+		t.Fatalf("文本不该报错: %v", err)
+	}
+	if x, ok := got.(time.Time); !ok || x.UnixMilli() != 1709642096123 {
+		t.Errorf("文本应解析成 UTC time.Time (ms=1709642096123), 实际 %v (%T)", got, got)
+	}
+
+	// 整数按毫秒原样落 (不是纳秒 —— 那是旧 Uint64 盘面的事)。
+	got, err = coerceForVRTX(types.FieldType_DateTime, 0, int64(1709642096123), "ctime", true, false)
+	if err != nil {
+		t.Fatalf("毫秒整数不该报错: %v", err)
+	}
+	if x, ok := got.(uint64); !ok || x != 1709642096123 {
+		t.Errorf("毫秒整数应按毫秒落 uint64, 实际 %v (%T)", got, got)
+	}
+
+	// 零时间: 可空列 → NULL; 非空列 → 零毫秒。
+	got, err = coerceForVRTX(types.FieldType_DateTime, 0, time.Time{}, "ctime", true, true)
+	if err != nil || got != nil {
+		t.Errorf("零时间走可空列应写 NULL, 实际 %v (%v)", got, err)
+	}
+	got, err = coerceForVRTX(types.FieldType_DateTime, 0, time.Time{}, "ctime", true, false)
+	if err != nil {
+		t.Fatalf("零时间走非空列不该报错: %v", err)
+	}
+	if x, ok := got.(uint64); !ok || x != 0 {
+		t.Errorf("零时间走非空列应落零毫秒, 实际 %v (%T)", got, got)
+	}
+
+	// nil 的约定不变: 可空列 NULL, 非空列零毫秒。
+	got, err = coerceForVRTX(types.FieldType_DateTime, 0, nil, "ctime", true, true)
+	if err != nil || got != nil {
+		t.Errorf("nil 走可空列应写 NULL, 实际 %v (%v)", got, err)
+	}
+	got, err = coerceForVRTX(types.FieldType_DateTime, 0, nil, "ctime", true, false)
+	if err != nil {
+		t.Fatalf("nil 走非空列不该报错: %v", err)
+	}
+	if x, ok := got.(uint64); !ok || x != 0 {
+		t.Errorf("nil 走非空列应落零毫秒 (zeroForVRTX), 实际 %v (%T)", got, got)
+	}
+}
+
 // TestCoerceForSQLUnsignedSmall 2 / 4 字节无符号档出库时必须给【无符号数值】。
 //
 // 与 8 字节档同一个道理 (见 TestCoerceForSQL8ByteByDialect), 但 16/32 位不需要按方言分岔: