Explorar o código

fix(driver): 实测报告修复 — 时间字面量/限定列名/LIMIT?/SELECT无FROM/UPSERT 报错

- 时间字面量: parseDateTimeLiteral 解析 GORM 内联的时间串
  ('YYYY-MM-DD HH:MM:SS[.fff]'), 零时间 (0001-01-01, 含 gorm.DeletedAt
  的 NullTime 串形态) 归一为 NULL — 修复软删除列被垃圾串污染 (#1/#3);
  DATETIME 类型映射统一为 FieldType_DateTime, Uint64 列保留时间宽容。
- 限定列名归一 (translateWhere 全路径剥 表名. 前缀): GORM 生成
  `t`.`deleted_at` IS NULL, 不剥则 NullCondition 静默恒假 /
  等值报列不存在 — UPDATE/DELETE 的软删除作用域 0 行根因 (#2)。
- LIMIT/OFFSET 接受 ? 占位符 (#5); SELECT 无 FROM 的字面量清单
  (SELECT 1 探活) (#7); INSERT ... ON CONFLICT/ON DUPLICATE 明确报错 (#9)。
- 新增 gorm_compat_test.go 5 组 (时间字面量/软删除作用域/LIMIT 占位/
  无 FROM/UPSERT 报错)。
yl32940 hai 3 semanas
pai
achega
103005583c
Modificáronse 5 ficheiros con 682 adicións e 38 borrados
  1. 83 5
      DB/driver/convert.go
  2. 167 15
      DB/driver/exec.go
  3. 329 0
      DB/driver/gorm_compat_test.go
  4. 14 5
      DB/driver/join.go
  5. 89 13
      DB/driver/parser.go

+ 83 - 5
DB/driver/convert.go

@@ -25,14 +25,45 @@ func driverToEngineValue(fi *types.FieldInfo, v driver.Value) (interface{}, erro
 		return nil, nil // NULL: 由引擎按 FlagNullable 裁决
 	}
 	switch fi.FieldType {
-	case types.FieldType_Uint64, types.FieldType_Int64,
+	case types.FieldType_DateTime:
+		// time.Time 原样 (引擎编码层转毫秒); 数值已是毫秒; 字符串走字面量解析,
+		// 其中零时间 (0001-01-01...) 解析出 nil → 返回 (nil, nil) = NULL,
+		// 与 buildFieldValue 的 NULL 语义汇合 (软删除 UPDATE SET deleted_at=零时间 ⇒ NULL)。
+		switch t := v.(type) {
+		case time.Time:
+			return t, nil
+		case int64:
+			if t < 0 {
+				return nil, fmt.Errorf("时间戳不能为负: %d", t)
+			}
+			return t, nil // 已是毫秒
+		case uint64:
+			return t, nil // 已是毫秒
+		case float64:
+			if t < 0 {
+				return nil, fmt.Errorf("时间戳不能为负: %v", t)
+			}
+			return int64(t), nil // 已是毫秒
+		case string:
+			return parseDateTimeLiteral(t)
+		case []byte:
+			return parseDateTimeLiteral(string(t))
+		}
+		return nil, typeMismatch(fi, v)
+	case types.FieldType_Uint64:
+		// 旧版驱动把 DATETIME 建成 Uint64 列: 整数之外宽容接受 time.Time /
+		// 时间文本 / 毫秒串 (toDateTimeMillis 统一按毫秒), 保证老表时间读写不回退。
+		switch v.(type) {
+		case time.Time, string, []byte:
+			return toDateTimeMillis(v)
+		}
+		return toInt64(v)
+	case types.FieldType_Int64,
 		types.FieldType_Uint32, types.FieldType_Int32,
 		types.FieldType_Uint16, types.FieldType_Int16,
 		types.FieldType_Uint8, types.FieldType_Int8,
 		types.FieldType_Year:
 		return toInt64(v)
-	case types.FieldType_DateTime:
-		return toDateTimeMillis(v)
 	case types.FieldType_Float64, types.FieldType_Float32:
 		return toFloat64(v)
 	case types.FieldType_Bool:
@@ -157,7 +188,18 @@ func parseLiteralAsType(fi *types.FieldInfo, s string) (interface{}, error) {
 		if f, err := strconv.ParseFloat(s, 64); err == nil {
 			return f, nil
 		}
-	case types.FieldType_Uint64, types.FieldType_Int64,
+	case types.FieldType_Uint64:
+		// 整数优先; 兼容旧版 DATETIME 建的 Uint64 列: 时间文本/毫秒串按毫秒解释。
+		if n, err := strconv.ParseInt(s, 10, 64); err == nil {
+			return n, nil
+		}
+		if f, ferr := strconv.ParseFloat(s, 64); ferr == nil {
+			return int64(f), nil
+		}
+		if ms, terr := parseDateTimeText(s); terr == nil {
+			return ms, nil
+		}
+	case types.FieldType_Int64,
 		types.FieldType_Uint32, types.FieldType_Int32,
 		types.FieldType_Uint16, types.FieldType_Int16,
 		types.FieldType_Uint8, types.FieldType_Int8,
@@ -175,7 +217,7 @@ func parseLiteralAsType(fi *types.FieldInfo, s string) (interface{}, error) {
 	case types.FieldType_Decimal:
 		return types.MustParseDecimal(s), nil
 	case types.FieldType_DateTime:
-		return parseDateTimeText(s)
+		return parseDateTimeLiteral(s)
 	default:
 		// 字符串/动态列: 原样
 		return s, nil
@@ -312,6 +354,42 @@ func sqlTypeToFieldInfo(c colDef) (types.FieldInfo, error) {
 // DateTime: driver 值 / 文本 → uint64 毫秒
 // =============================================================================
 
+// dateTimeLiteralLayouts parseDateTimeLiteral 尝试的时间文本布局 (按顺序;
+// 无时区者按本地时区解释)。
+var dateTimeLiteralLayouts = []string{
+	"2006-01-02 15:04:05.999999999",
+	"2006-01-02 15:04:05",
+	"2006-01-02T15:04:05.999999999",
+	"2006-01-02",
+}
+
+// parseDateTimeLiteral 解析 SQL 文本里的日期时间字面量。GORM 会把 time.Time
+// 族字段的值【内联成 SQL 字符串字面量】而不是 ? 参数, 零值 gorm.DeletedAt
+// 更是被序列化成 "{0001-01-01 00:00:00 +0000 UTC false}" 这样的垃圾串。
+//
+// 约定:
+//   - 剥掉首尾空白与 {} 包裹;
+//   - 以 "0001-01-01" 开头 = 零时间, 返回 (nil, nil) → 调用方按 NULL 处理
+//     (GORM 软删除的未删除态正是零 DeletedAt ⇔ deleted_at IS NULL);
+//   - 其余按 dateTimeLiteralLayouts 解析, 成功返回 time.Time (引擎编码层
+//     负责 time.Time → uint64 毫秒);
+//   - 全部失败返回含原文的明确错误。
+func parseDateTimeLiteral(s string) (interface{}, error) {
+	t := strings.TrimSpace(s)
+	if len(t) >= 2 && t[0] == '{' && t[len(t)-1] == '}' {
+		t = strings.TrimSpace(t[1 : len(t)-1])
+	}
+	if strings.HasPrefix(t, "0001-01-01") {
+		return nil, nil // 零时间 = NULL
+	}
+	for _, layout := range dateTimeLiteralLayouts {
+		if ts, err := time.ParseInLocation(layout, t, time.Local); err == nil {
+			return ts, nil
+		}
+	}
+	return nil, fmt.Errorf("无法把 %q 解析为日期时间字面量 (支持 'YYYY-MM-DD HH:MM:SS[.小数]'、'YYYY-MM-DDThh:mm:ss[.小数]'、'YYYY-MM-DD'; 0001-01-01 零时间视为 NULL)", s)
+}
+
 // timeLayouts 接受的时间文本格式 (按尝试顺序; 无时区者按本地时区解释)。
 var timeLayouts = []string{
 	time.RFC3339Nano,

+ 167 - 15
DB/driver/exec.go

@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"math"
 	"reflect"
+	"strconv"
 	"strings"
 	"time"
 
@@ -451,6 +452,10 @@ func columnKeyIndicator(tbl *core.Table, target int) string {
 // JOIN 查询 (join.go) 物化嵌套循环执行, 算完即放锁。
 // release 非 nil 表示锁由本次查询 (非事务) 获取, 任何提前返回错误都必须 release。
 func queryStatement(ctx context.Context, db *core.DB, sel *selectStmt, args []driver.NamedValue, release func()) (driver.Rows, error) {
+	// SELECT 值清单 (无 FROM, 如探活语句 SELECT 1): 不触引擎, 物化单行立即返回。
+	if sel.noFrom {
+		return queryLiteralRow(sel, args, release)
+	}
 	if len(sel.joins) > 0 {
 		return queryJoin(db, sel, args, release)
 	}
@@ -514,10 +519,18 @@ func queryStatement(ctx context.Context, db *core.DB, sel *selectStmt, args []dr
 		q = q.OrderBy(o.col, o.desc)
 	}
 	if sel.limit != nil {
-		q = q.Limit(int(*sel.limit))
+		lim, err := resolveLimitValue("LIMIT", sel.limit, args)
+		if err != nil {
+			return fail(err)
+		}
+		q = q.Limit(int(lim))
 	}
 	if sel.offset != nil {
-		q = q.Offset(int(*sel.offset))
+		off, err := resolveLimitValue("OFFSET", sel.offset, args)
+		if err != nil {
+			return fail(err)
+		}
+		q = q.Offset(int(off))
 	}
 
 	r := newStreamRows(colNames, metas, release)
@@ -525,6 +538,128 @@ func queryStatement(ctx context.Context, db *core.DB, sel *selectStmt, args []dr
 	return r, nil
 }
 
+// queryLiteralRow 执行无 FROM 的 SELECT 值清单 ("SELECT 1" / "SELECT 'a', NULL"):
+// 每个值物化成 driver.Value, 拼成单行结果。不触引擎, 立即放锁。
+func queryLiteralRow(sel *selectStmt, args []driver.NamedValue, release func()) (driver.Rows, error) {
+	if release != nil {
+		defer release()
+	}
+	cols := make([]string, len(sel.literalVals))
+	metas := make([]colMeta, len(sel.literalVals))
+	row := make([]driver.Value, len(sel.literalVals))
+	for i, v := range sel.literalVals {
+		cols[i] = "?column?"
+		dv, m, err := resolveLiteralValue(v, args)
+		if err != nil {
+			return nil, fmt.Errorf("SELECT 值清单第 %d 项: %w", i+1, err)
+		}
+		metas[i] = m
+		row[i] = dv
+	}
+	return materialRows(cols, metas, [][]driver.Value{row}), nil
+}
+
+// resolveLiteralValue 把无 FROM 值清单里的字面量/占位符物化成 driver.Value,
+// 并按值的种类给出列元数据 (供 ColumnType* 反射)。
+func resolveLiteralValue(v value, args []driver.NamedValue) (driver.Value, colMeta, error) {
+	intMeta := colMeta{dbName: "BIGINT", nullable: false, scanType: reflect.TypeOf(int64(0))}
+	floatMeta := colMeta{dbName: "DOUBLE", nullable: false, scanType: reflect.TypeOf(float64(0))}
+	strMeta := colMeta{dbName: "VARCHAR", nullable: false, scanType: reflect.TypeOf("")}
+	nullMeta := colMeta{dbName: "NULL", nullable: true, scanType: reflect.TypeOf("")}
+	switch t := v.(type) {
+	case nullValue:
+		return nil, nullMeta, nil
+	case strValue:
+		return []byte(t.s), strMeta, nil
+	case litValue:
+		raw := strings.TrimSpace(t.raw)
+		if strings.Contains(raw, ".") {
+			f, err := strconv.ParseFloat(raw, 64)
+			if err != nil {
+				return nil, colMeta{}, fmt.Errorf("浮点字面量 %q: %w", t.raw, err)
+			}
+			return f, floatMeta, nil
+		}
+		n, err := strconv.ParseInt(raw, 10, 64)
+		if err != nil {
+			return nil, colMeta{}, fmt.Errorf("整数字面量 %q: %w", t.raw, err)
+		}
+		return n, intMeta, nil
+	case argValue:
+		if t.idx >= len(args) {
+			return nil, colMeta{}, fmt.Errorf("占位符 ?%d 没有对应参数 (共 %d 个)", t.idx+1, len(args))
+		}
+		switch x := args[t.idx].Value.(type) {
+		case nil:
+			return nil, nullMeta, nil
+		case int64:
+			return x, intMeta, nil
+		case float64:
+			return x, floatMeta, nil
+		case bool:
+			return x, colMeta{dbName: "BOOLEAN", nullable: false, scanType: reflect.TypeOf(false)}, nil
+		case string:
+			return []byte(x), strMeta, nil
+		case []byte:
+			return x, strMeta, nil
+		case time.Time:
+			return x, colMeta{dbName: "DATETIME", nullable: false, scanType: reflect.TypeOf(time.Time{})}, nil
+		}
+		return nil, colMeta{}, fmt.Errorf("参数类型 %T 不能出现在 SELECT 值清单", args[t.idx].Value)
+	}
+	return nil, colMeta{}, fmt.Errorf("无法解析的值")
+}
+
+// resolveLimitValue 解析 LIMIT/OFFSET 的量: 字面量直解整数; 占位符从 args
+// 取整数 (int64/uint64/数字字符串/整值浮点均可, 非整数报错)。
+func resolveLimitValue(keyword string, v value, args []driver.NamedValue) (int64, error) {
+	switch t := v.(type) {
+	case litValue:
+		n, err := strconv.ParseInt(strings.TrimSpace(t.raw), 10, 64)
+		if err != nil {
+			return 0, fmt.Errorf("%s 期望整数, 实际是 %q", keyword, t.raw)
+		}
+		return n, nil
+	case argValue:
+		if t.idx >= len(args) {
+			return 0, fmt.Errorf("占位符 ?%d 没有对应参数 (共 %d 个)", t.idx+1, len(args))
+		}
+		n, err := limitArgToInt64(args[t.idx].Value)
+		if err != nil {
+			return 0, fmt.Errorf("%s: %w", keyword, err)
+		}
+		return n, nil
+	}
+	return 0, fmt.Errorf("%s 只支持整数或 ? 占位符", keyword)
+}
+
+// limitArgToInt64 把 LIMIT/OFFSET 的参数值规整成 int64。
+func limitArgToInt64(v driver.Value) (int64, error) {
+	switch x := v.(type) {
+	case int64:
+		return x, nil
+	case uint64:
+		if x > math.MaxInt64 {
+			return 0, fmt.Errorf("超出 int64 范围: %d", x)
+		}
+		return int64(x), nil
+	case float64:
+		if x != math.Trunc(x) {
+			return 0, fmt.Errorf("参数必须是非负整数, 实际 %v", x)
+		}
+		return int64(x), nil
+	case string:
+		n, err := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
+		if err != nil {
+			return 0, fmt.Errorf("参数 %q 不是整数", x)
+		}
+		return n, nil
+	case []byte:
+		return limitArgToInt64(string(x))
+	}
+	return 0, fmt.Errorf("参数必须是整数, 实际 %T", v)
+}
+
 func queryAggregate(tbl *core.Table, sel *selectStmt, args []driver.NamedValue) (*rows, error) {
 	cond, err := translateWhere(tbl, sel.where, args)
 	if err != nil {
@@ -732,7 +867,8 @@ func translateWhere(tbl *core.Table, e expr, args []driver.NamedValue) (core.Con
 		}
 		return core.Not(inner), nil
 	case *cmpExpr:
-		fi := findField(tbl, t.col)
+		col := resolveColRef(t.col)
+		fi := findField(tbl, col)
 		if fi == nil {
 			return nil, fmt.Errorf("%w (WHERE): %q", core.ErrFieldNotFound, t.col)
 		}
@@ -742,17 +878,17 @@ func translateWhere(tbl *core.Table, e expr, args []driver.NamedValue) (core.Con
 		}
 		switch t.op {
 		case "=":
-			return core.Eq(t.col, v, fi.FieldType), nil
+			return core.Eq(col, v, fi.FieldType), nil
 		case "!=":
-			return core.Not(core.Eq(t.col, v, fi.FieldType)), nil
+			return core.Not(core.Eq(col, v, fi.FieldType)), nil
 		case ">":
-			return core.Gt(t.col, v, fi.FieldType), nil
+			return core.Gt(col, v, fi.FieldType), nil
 		case ">=":
-			return core.Ge(t.col, v, fi.FieldType), nil
+			return core.Ge(col, v, fi.FieldType), nil
 		case "<":
-			return core.Lt(t.col, v, fi.FieldType), nil
+			return core.Lt(col, v, fi.FieldType), nil
 		case "<=":
-			return core.Le(t.col, v, fi.FieldType), nil
+			return core.Le(col, v, fi.FieldType), nil
 		}
 		return nil, fmt.Errorf("不支持的比较符 %q", t.op)
 	case *colCmpExpr:
@@ -760,7 +896,8 @@ func translateWhere(tbl *core.Table, e expr, args []driver.NamedValue) (core.Con
 		// 组合行上求值; 单表/UPDATE/DELETE 走到这里说明用法越界, 明确报错。
 		return nil, fmt.Errorf("列与列的比较 (%s %s %s) 仅在多表 JOIN 查询中支持", t.left, t.op, t.right)
 	case *inExpr:
-		fi := findField(tbl, t.col)
+		col := resolveColRef(t.col)
+		fi := findField(tbl, col)
 		if fi == nil {
 			return nil, fmt.Errorf("%w (WHERE): %q", core.ErrFieldNotFound, t.col)
 		}
@@ -772,7 +909,7 @@ func translateWhere(tbl *core.Table, e expr, args []driver.NamedValue) (core.Con
 			}
 			vals = append(vals, v)
 		}
-		c := core.In(t.col, vals, fi.FieldType)
+		c := core.In(col, vals, fi.FieldType)
 		if t.neg {
 			return core.Not(c), nil
 		}
@@ -780,12 +917,16 @@ func translateWhere(tbl *core.Table, e expr, args []driver.NamedValue) (core.Con
 	case *isNullExpr:
 		// IS [NOT] NULL → 引擎的 NullCondition: 直接读行内 NULL 位图
 		// (与反序列化/索引提取同一口径; Eq 对 NULL 行一律不匹配, 表达不了 IS NULL)
+		// 列名同样要剥限定前缀 — GORM 生成 `t`.`deleted_at` IS NULL,
+		// 不剥的话 NullCondition 按全名找不到列、静默恒假 (UPDATE/DELETE 0 行)。
+		col := resolveColRef(t.col)
 		if t.neg {
-			return core.IsNotNull(t.col), nil
+			return core.IsNotNull(col), nil
 		}
-		return core.IsNull(t.col), nil
+		return core.IsNull(col), nil
 	case *likeExpr:
-		fi := findField(tbl, t.col)
+		col := resolveColRef(t.col)
+		fi := findField(tbl, col)
 		if fi == nil {
 			return nil, fmt.Errorf("%w (WHERE): %q", core.ErrFieldNotFound, t.col)
 		}
@@ -793,7 +934,7 @@ func translateWhere(tbl *core.Table, e expr, args []driver.NamedValue) (core.Con
 		if err != nil {
 			return nil, err
 		}
-		c := core.Like(t.col, pat)
+		c := core.Like(col, pat)
 		if t.neg {
 			return core.Not(c), nil
 		}
@@ -802,6 +943,17 @@ func translateWhere(tbl *core.Table, e expr, args []driver.NamedValue) (core.Con
 	return nil, fmt.Errorf("无法翻译的 WHERE 表达式")
 }
 
+// resolveColRef 列名归一: 剥掉 "表名." / "别名." 限定前缀。GORM 等 ORM 生成的
+// SQL 普遍带反引号限定 (`t`.`col`, 词法层拼成 "t.col"), 而引擎条件按裸列名
+// 匹配 — 不剥的话: 等值/IN/LIKE 报"列不存在", IS NULL 按全名找不到列、
+// Match 静默恒假 (UPDATE/DELETE 作用 0 行, 无任何报错)。
+func resolveColRef(col string) string {
+	if i := strings.LastIndexByte(col, '.'); i >= 0 {
+		return col[i+1:]
+	}
+	return col
+}
+
 // =============================================================================
 // 表/列/值解析辅助
 // =============================================================================

+ 329 - 0
DB/driver/gorm_compat_test.go

@@ -0,0 +1,329 @@
+package vrtxsql
+
+// GORM 兼容回归测试 (报告 #1/#2/#3 同根因):
+//
+// GORM 把时间族字段的值【内联成 SQL 字符串字面量】而不是 ? 参数, 零值
+// gorm.DeletedAt 被序列化成 "{0001-01-01 00:00:00 +0000 UTC false}" 垃圾串。
+// 若驱动把它当普通文本存进 DATETIME 列, 该行 deleted_at 就不是 NULL,
+// `deleted_at IS NULL` 恒 0 行 → GORM 软删除作用域下的 UPDATE/DELETE/First
+// 全部失效。本文件覆盖四个修复: 时间字面量解析、LIMIT/OFFSET 占位符、
+// SELECT 无 FROM 的探活语句、ON CONFLICT 友好报错。
+
+import (
+	"database/sql"
+	"strings"
+	"testing"
+	"time"
+)
+
+// TestGormDateTimeLiteral 修复 1: DATETIME 列的内联时间字面量。
+// 非零字面量 → 毫秒正确落库; 零时间 (含 GORM 垃圾串) → NULL;
+// UPDATE SET 零时间 → 列变 NULL; WHERE dt IS NULL 按 NULL 位图命中。
+func TestGormDateTimeLiteral(t *testing.T) {
+	db := openTestDB(t)
+	if _, err := db.Exec(`CREATE TABLE tasks (
+		id BIGINT PRIMARY KEY,
+		name VARCHAR(32),
+		deleted_at DATETIME
+	)`); err != nil {
+		t.Fatalf("create: %v", err)
+	}
+
+	// GORM 内联非零时间字面量 (MySQL 格式, 带毫秒) —— 不再是 ? 参数
+	ts := time.Date(2026, 9, 30, 0, 8, 59, 839*int(time.Millisecond), time.Local)
+	if _, err := db.Exec(`INSERT INTO tasks (id, name, deleted_at) VALUES (1, 'a', '2026-09-30 00:08:59.839')`); err != nil {
+		t.Fatalf("insert 时间字面量: %v", err)
+	}
+	// ? 参数 time.Time 路径保持好用 (回归)
+	if _, err := db.Exec(`INSERT INTO tasks (id, name, deleted_at) VALUES (?, ?, ?)`,
+		2, "b", ts.Add(24*time.Hour)); err != nil {
+		t.Fatalf("insert time.Time 参数: %v", err)
+	}
+	// GORM 零值 DeletedAt 的垃圾串 → 必须是 NULL 而不是垃圾文本
+	if _, err := db.Exec(`INSERT INTO tasks (id, name, deleted_at) VALUES (3, 'c', '{0001-01-01 00:00:00 +0000 UTC false}')`); err != nil {
+		t.Fatalf("insert 零时间垃圾串: %v", err)
+	}
+	// 不带 {} 的裸零时间字面量同样按 NULL
+	if _, err := db.Exec(`INSERT INTO tasks (id, name, deleted_at) VALUES (4, 'd', '0001-01-01 00:00:00')`); err != nil {
+		t.Fatalf("insert 裸零时间: %v", err)
+	}
+
+	// 非零字面量回读毫秒正确
+	var got time.Time
+	if err := db.QueryRow(`SELECT deleted_at FROM tasks WHERE id = 1`).Scan(&got); err != nil {
+		t.Fatalf("回读 id=1: %v", err)
+	}
+	if !got.Equal(ts) {
+		t.Fatalf("id=1 时间=%v, 想要 %v", got, ts)
+	}
+
+	// 垃圾串行 deleted_at 必须是 NULL
+	var nd sql.NullTime
+	if err := db.QueryRow(`SELECT deleted_at FROM tasks WHERE id = 3`).Scan(&nd); err != nil {
+		t.Fatalf("回读 id=3: %v", err)
+	}
+	if nd.Valid {
+		t.Fatalf("id=3 deleted_at 应为 NULL, 实际 %v", nd.Time)
+	}
+	if err := db.QueryRow(`SELECT deleted_at FROM tasks WHERE id = 4`).Scan(&nd); err != nil {
+		t.Fatalf("回读 id=4: %v", err)
+	}
+	if nd.Valid {
+		t.Fatalf("id=4 deleted_at 应为 NULL, 实际 %v", nd.Time)
+	}
+
+	// WHERE 时间字面量等值 (字面量 → time.Time → 引擎按列编码比较)
+	var id int64
+	if err := db.QueryRow(`SELECT id FROM tasks WHERE deleted_at = '2026-09-30 00:08:59.839'`).Scan(&id); err != nil {
+		t.Fatalf("WHERE 时间字面量: %v", err)
+	}
+	if id != 1 {
+		t.Fatalf("WHERE 字面量命中 id=%d, 想要 1", id)
+	}
+
+	// IS NULL / IS NOT NULL 命中数
+	var cnt int
+	if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE deleted_at IS NULL`).Scan(&cnt); err != nil || cnt != 2 {
+		t.Fatalf("IS NULL: cnt=%d err=%v, 想要 2", cnt, err)
+	}
+	if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE deleted_at IS NOT NULL`).Scan(&cnt); err != nil || cnt != 2 {
+		t.Fatalf("IS NOT NULL: cnt=%d err=%v, 想要 2", cnt, err)
+	}
+
+	// UPDATE SET 零时间垃圾串 → 列变 NULL (软删除恢复/置空语义)
+	res, err := db.Exec(`UPDATE tasks SET deleted_at = '{0001-01-01 00:00:00 +0000 UTC false}' WHERE id = 1`)
+	if err != nil {
+		t.Fatalf("UPDATE 零时间: %v", err)
+	}
+	if n, _ := res.RowsAffected(); n != 1 {
+		t.Fatalf("UPDATE 零时间 RowsAffected=%d, 想要 1", n)
+	}
+	if err := db.QueryRow(`SELECT deleted_at FROM tasks WHERE id = 1`).Scan(&nd); err != nil {
+		t.Fatalf("回读 UPDATE 后 id=1: %v", err)
+	}
+	if nd.Valid {
+		t.Fatalf("UPDATE 后 id=1 deleted_at 应为 NULL, 实际 %v", nd.Time)
+	}
+
+	// UPDATE SET 非零时间字面量 → 正常写入
+	t2 := time.Date(2027, 1, 15, 10, 30, 0, 123*int(time.Millisecond), time.Local)
+	if _, err := db.Exec(`UPDATE tasks SET deleted_at = '2027-01-15 10:30:00.123' WHERE id = 3`); err != nil {
+		t.Fatalf("UPDATE 非零时间: %v", err)
+	}
+	if err := db.QueryRow(`SELECT deleted_at FROM tasks WHERE id = 3`).Scan(&got); err != nil {
+		t.Fatalf("回读 UPDATE 后 id=3: %v", err)
+	}
+	if !got.Equal(t2) {
+		t.Fatalf("UPDATE 后 id=3 时间=%v, 想要 %v", got, t2)
+	}
+}
+
+// TestGormSoftDeleteScope 修复 1+2: GORM 软删除作用域的 UPDATE 走 database/sql
+// 全链路 (GORM 矩阵同款: WHERE id=? AND deleted_at IS NULL)。
+// 零 DeletedAt 行必须命中且 RowsAffected=1; 已软删行必须不命中。
+func TestGormSoftDeleteScope(t *testing.T) {
+	db := openTestDB(t)
+	if _, err := db.Exec(`CREATE TABLE users (
+		id BIGINT PRIMARY KEY,
+		name VARCHAR(32),
+		deleted_at DATETIME
+	)`); err != nil {
+		t.Fatalf("create: %v", err)
+	}
+
+	// GORM Create: 零 DeletedAt 内联成垃圾串 → deleted_at 必须是 NULL,
+	// 否则后续软删除作用域全失效 (报告 #1/#2/#3 的根因)。
+	if _, err := db.Exec(`INSERT INTO users (id, name, deleted_at) VALUES (?, ?, ?)`,
+		1, "alice", "{0001-01-01 00:00:00 +0000 UTC false}"); err != nil {
+		t.Fatalf("insert: %v", err)
+	}
+
+	// GORM 软删除 UPDATE 作用域: UPDATE ... WHERE id=? AND deleted_at IS NULL
+	res, err := db.Exec(`UPDATE users SET name = ? WHERE id = ? AND deleted_at IS NULL`, "alice2", 1)
+	if err != nil {
+		t.Fatalf("软删除作用域 UPDATE: %v", err)
+	}
+	if n, _ := res.RowsAffected(); n != 1 {
+		t.Fatalf("RowsAffected=%d, 想要 1 (deleted_at 垃圾串会让它变 0)", n)
+	}
+
+	// GORM Delete (软删): SET deleted_at='<当前时间>' WHERE ... AND deleted_at IS NULL
+	now := time.Now().Truncate(time.Millisecond)
+	if _, err := db.Exec(`UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`, now, 1); err != nil {
+		t.Fatalf("软删: %v", err)
+	}
+	// 已软删 → 同作用域再 UPDATE 必须 0 行
+	res, err = db.Exec(`UPDATE users SET name = ? WHERE id = ? AND deleted_at IS NULL`, "alice3", 1)
+	if err != nil {
+		t.Fatalf("已软删后再 UPDATE: %v", err)
+	}
+	if n, _ := res.RowsAffected(); n != 0 {
+		t.Fatalf("已软删行 RowsAffected=%d, 想要 0", n)
+	}
+	var cnt int
+	if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`).Scan(&cnt); err != nil || cnt != 0 {
+		t.Fatalf("软删后 IS NULL 行数=%d err=%v, 想要 0", cnt, err)
+	}
+}
+
+// TestLimitOffsetPlaceholders 修复 2: LIMIT/OFFSET 接受 ? 占位符。
+func TestLimitOffsetPlaceholders(t *testing.T) {
+	db := openTestDB(t)
+	if _, err := db.Exec(`CREATE TABLE t (id BIGINT PRIMARY KEY)`); err != nil {
+		t.Fatal(err)
+	}
+	for i := 1; i <= 10; i++ {
+		if _, err := db.Exec(`INSERT INTO t (id) VALUES (?)`, i); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	// LIMIT ? OFFSET ?
+	var got []int64
+	rows, err := db.Query(`SELECT id FROM t ORDER BY id LIMIT ? OFFSET ?`, 3, 2)
+	if err != nil {
+		t.Fatalf("LIMIT ? OFFSET ?: %v", err)
+	}
+	for rows.Next() {
+		var id int64
+		if err := rows.Scan(&id); err != nil {
+			t.Fatal(err)
+		}
+		got = append(got, id)
+	}
+	rows.Close()
+	if len(got) != 3 || got[0] != 3 || got[1] != 4 || got[2] != 5 {
+		t.Fatalf("LIMIT ? OFFSET ? 得 %v, 想要 [3 4 5]", got)
+	}
+
+	// MySQL 写法 LIMIT offset,count 全占位
+	got = got[:0]
+	rows, err = db.Query(`SELECT id FROM t ORDER BY id LIMIT ?, ?`, 2, 3)
+	if err != nil {
+		t.Fatalf("LIMIT ?,?: %v", err)
+	}
+	for rows.Next() {
+		var id int64
+		if err := rows.Scan(&id); err != nil {
+			t.Fatal(err)
+		}
+		got = append(got, id)
+	}
+	rows.Close()
+	if len(got) != 3 || got[0] != 3 || got[1] != 4 || got[2] != 5 {
+		t.Fatalf("LIMIT ?,? 得 %v, 想要 [3 4 5]", got)
+	}
+
+	// 字面量路径兼容 (回归)
+	var one int64
+	if err := db.QueryRow(`SELECT id FROM t ORDER BY id LIMIT 1`).Scan(&one); err != nil || one != 1 {
+		t.Fatalf("字面量 LIMIT: one=%d err=%v", one, err)
+	}
+
+	// 非整数参数必须报错
+	if _, err := db.Query(`SELECT id FROM t LIMIT ?`, 1.5); err == nil {
+		t.Fatalf("LIMIT 1.5 应报错")
+	}
+	if _, err := db.Query(`SELECT id FROM t LIMIT ?`, "abc"); err == nil {
+		t.Fatalf("LIMIT 'abc' 应报错")
+	}
+	// Prepare 路径 (stmt.NumInput 必须把占位符计入)
+	stmt, err := db.Prepare(`SELECT id FROM t ORDER BY id LIMIT ? OFFSET ?`)
+	if err != nil {
+		t.Fatalf("prepare: %v", err)
+	}
+	defer stmt.Close()
+	got = got[:0]
+	srows, err := stmt.Query(2, 4)
+	if err != nil {
+		t.Fatalf("stmt query: %v", err)
+	}
+	for srows.Next() {
+		var id int64
+		if err := srows.Scan(&id); err != nil {
+			t.Fatal(err)
+		}
+		got = append(got, id)
+	}
+	srows.Close()
+	if len(got) != 2 || got[0] != 5 || got[1] != 6 {
+		t.Fatalf("prepare LIMIT ? OFFSET ? 得 %v, 想要 [5 6]", got)
+	}
+}
+
+// TestSelectNoFrom 修复 3: SELECT 无 FROM 的字面量值清单 (探活语句)。
+func TestSelectNoFrom(t *testing.T) {
+	db := openTestDB(t)
+
+	// SELECT 1 —— 最常见的探活语句
+	var n int64
+	if err := db.QueryRow(`SELECT 1`).Scan(&n); err != nil || n != 1 {
+		t.Fatalf("SELECT 1: n=%d err=%v", n, err)
+	}
+
+	// 值清单: 数字 / 字符串 / NULL 混排
+	rows, err := db.Query(`SELECT 'a', 1, NULL`)
+	if err != nil {
+		t.Fatalf("SELECT 值清单: %v", err)
+	}
+	defer rows.Close()
+	cols, err := rows.Columns()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(cols) != 3 {
+		t.Fatalf("列数=%d, 想要 3", len(cols))
+	}
+	if !rows.Next() {
+		t.Fatal("值清单应恰有一行")
+	}
+	var s []byte
+	var i int64
+	var nu sql.NullInt64
+	if err := rows.Scan(&s, &i, &nu); err != nil {
+		t.Fatalf("scan: %v", err)
+	}
+	if string(s) != "a" || i != 1 || nu.Valid {
+		t.Fatalf("值=%q %d %v, 想要 a 1 NULL", s, i, nu)
+	}
+	if rows.Next() {
+		t.Fatal("值清单应只有一行")
+	}
+
+	// 浮点字面量
+	var f float64
+	if err := db.QueryRow(`SELECT 1.5`).Scan(&f); err != nil || f != 1.5 {
+		t.Fatalf("SELECT 1.5: f=%v err=%v", f, err)
+	}
+
+	// 表达式/函数不支持 → 明确错误而非静默做错
+	if _, err := db.Query(`SELECT 1 + 1`); err == nil {
+		t.Fatalf("SELECT 1 + 1 应报错 (不支持表达式)")
+	}
+	if _, err := db.Query(`SELECT COUNT(*)`); err == nil {
+		t.Fatalf("SELECT COUNT(*) 无 FROM 应报错")
+	}
+}
+
+// TestOnConflictFriendlyError 修复 4: UPSERT 语法给出明确报错文案。
+func TestOnConflictFriendlyError(t *testing.T) {
+	db := openTestDB(t)
+	if _, err := db.Exec(`CREATE TABLE t (id BIGINT PRIMARY KEY, v INT)`); err != nil {
+		t.Fatal(err)
+	}
+
+	cases := []string{
+		`INSERT INTO t (id, v) VALUES (1, 2) ON CONFLICT (id) DO UPDATE SET v = 3`,
+		`INSERT INTO t (id, v) VALUES (1, 2) ON DUPLICATE KEY UPDATE v = 3`,
+	}
+	for _, q := range cases {
+		_, err := db.Exec(q)
+		if err == nil {
+			t.Fatalf("%q 应报错", q)
+		}
+		if !strings.Contains(err.Error(), "不支持 UPSERT") ||
+			!strings.Contains(err.Error(), "ON CONFLICT/ON DUPLICATE KEY UPDATE") {
+			t.Fatalf("报错文案不对: %v", err)
+		}
+	}
+}

+ 14 - 5
DB/driver/join.go

@@ -957,15 +957,24 @@ func queryJoin(db *core.DB, sel *selectStmt, args []driver.NamedValue, release f
 		})
 	}
 	if sel.offset != nil {
-		o := int(*sel.offset)
-		if o >= len(rows) {
+		o, err := resolveLimitValue("OFFSET", sel.offset, args)
+		if err != nil {
+			return nil, err
+		}
+		if int(o) >= len(rows) {
 			rows = nil
 		} else {
-			rows = rows[o:]
+			rows = rows[int(o):]
 		}
 	}
-	if sel.limit != nil && int(*sel.limit) < len(rows) {
-		rows = rows[:int(*sel.limit)]
+	if sel.limit != nil {
+		l, err := resolveLimitValue("LIMIT", sel.limit, args)
+		if err != nil {
+			return nil, err
+		}
+		if int(l) < len(rows) {
+			rows = rows[:int(l)]
+		}
 	}
 
 	// 物化投影行 (DISTINCT 按投影值去重)

+ 89 - 13
DB/driver/parser.go

@@ -6,7 +6,8 @@
 // 解析器是刻意收窄的【ORM 子集翻译器】, 不是通用 SQL 引擎:
 //
 //	SELECT cols|* FROM t [JOIN|INNER JOIN|LEFT [OUTER] JOIN t2 ON a.x = b.y ...]
-//	                    [WHERE ...] [ORDER BY c [ASC|DESC], ...] [LIMIT n] [OFFSET n]
+//	                    [WHERE ...] [ORDER BY c [ASC|DESC], ...] [LIMIT n|?] [OFFSET n|?]
+//	SELECT 值清单              (无 FROM, 仅字面量/NULL/? —— 探活语句 SELECT 1)
 //	SELECT COUNT(*)|COUNT/SUM/MIN/MAX/AVG(col) FROM t [WHERE ...]          (无 GROUP BY)
 //	INSERT INTO t [(cols)] VALUES (...),(...)
 //	UPDATE t SET c=v,... [WHERE ...]
@@ -18,8 +19,9 @@
 //	WHERE 支持: = != <> < <= > >= AND OR NOT (..) IN (..) IS [NOT] NULL LIKE。
 //	JOIN 支持: 多表链式 [INNER] JOIN / LEFT [OUTER] JOIN, ON 仅等值 a.x = b.y;
 //	列引用允许限定名 (别名.列 / 表名.列), 跨表条件作为残余谓词在组合行上过滤。
-//	不支持: 子查询/GROUP BY/HAVING/函数表达式/DEFAULT 非 NULL 值 —— 遇到
-//	返回明确错误而不是静默做错。
+//	LIMIT/OFFSET 接受整数字面量或 ? 占位符 (GORM 的 Limit/Offset 走参数)。
+//	不支持: 子查询/GROUP BY/HAVING/函数表达式/DEFAULT 非 NULL 值/UPSERT
+//	(ON CONFLICT/ON DUPLICATE KEY UPDATE —— 遇到返回明确错误引导改用 Updates)。
 package vrtxsql
 
 import (
@@ -66,6 +68,11 @@ func (s *statement) argCount() int {
 	switch s.kind {
 	case kSelect:
 		n = exprMaxArg(s.sel.where)
+		n = max2(n, valueMaxArg(s.sel.limit))
+		n = max2(n, valueMaxArg(s.sel.offset))
+		for _, v := range s.sel.literalVals {
+			n = max2(n, valueMaxArg(v))
+		}
 	case kInsert:
 		for _, row := range s.ins.rows {
 			for _, v := range row {
@@ -124,16 +131,28 @@ func max2(a, b int) int {
 	return b
 }
 
+// valueMaxArg 值里的 ? 占位符占到的最大参数序号 (无占位符返回 0)。
+func valueMaxArg(v value) int {
+	if a, ok := v.(argValue); ok {
+		return a.idx + 1
+	}
+	return 0
+}
+
 type selectStmt struct {
-	from     tableRef // FROM 表 (alias 缺省 = 表名, 限定列名归一化用)
+	from     tableRef // FROM 表 (alias 缺省 = 表名, 限定列名归一化用); noFrom 时为空
 	joins    []joinClause
 	columns  []string // 空 = *; 元素可为 "*"(全表) / "t.*"(指定表全列) / 列名 / "t.列名"
 	distinct bool     // SELECT DISTINCT
 	agg      *aggSpec // 非空 = 单行聚合
 	where    expr
 	orderBy  []orderSpec
-	limit    *int64
-	offset   *int64
+	limit    value // LIMIT 量: litValue(整数) 或 argValue(? 占位); nil = 无
+	offset   value // OFFSET 量, 同上
+	// noFrom = true 表示 "SELECT 值清单" 无 FROM (探活语句 SELECT 1 等):
+	// literalVals 只能是字面量 (数字/字符串/NULL) 或 ? 占位, 执行成单行走结果。
+	noFrom      bool
+	literalVals []value
 }
 
 // tableRef FROM/JOIN 里的表引用: 表名 + 别名 (AS 可省略)。
@@ -610,6 +629,27 @@ func (p *parser) parseSelect() (*statement, error) {
 		sel.distinct = true
 	}
 
+	// SELECT 值清单 (无 FROM): 探活语句 "SELECT 1" / "SELECT 'a', 1"。值只能是
+	// 字面量 (数字/字符串/NULL) 或 ? 占位 —— 不支持表达式/函数/聚合, 遇到这类
+	// 开头直接走普通列清单解析, 会在期望 FROM 处给出明确错误。
+	if isSelectLiteralStart(p.peek()) {
+		sel.noFrom = true
+		for {
+			v, err := p.parseValue()
+			if err != nil {
+				return nil, err
+			}
+			sel.literalVals = append(sel.literalVals, v)
+			if !p.acceptPunct(",") {
+				break
+			}
+		}
+		if p.peek().kind != tkEOF {
+			return nil, fmt.Errorf("SELECT 值清单 (无 FROM) 仅支持字面量, 遇到多余内容 %q (表达式/别名/函数请改用带 FROM 的查询)", p.peek().text)
+		}
+		return &statement{kind: kSelect, sel: sel}, nil
+	}
+
 	// SELECT 列表: * / t.* / 列名 / t.列名 (可混用); 聚合单行另走分支
 	if p.peek().kind == tkKeyword && isAggFn(p.peek().text) {
 		agg, err := p.parseAgg()
@@ -693,32 +733,62 @@ func (p *parser) parseSelect() (*statement, error) {
 		}
 	}
 	if p.acceptKeyword("LIMIT") {
-		v, err := p.parseIntLiteral()
+		v, err := p.parseLimitValue()
 		if err != nil {
 			return nil, err
 		}
 		if p.acceptPunct(",") {
 			// MySQL 兼容: LIMIT offset, count (逗号前是偏移量, 后是行数)
-			c, err := p.parseIntLiteral()
+			c, err := p.parseLimitValue()
 			if err != nil {
 				return nil, err
 			}
-			sel.offset = &v
-			sel.limit = &c
+			sel.offset = v
+			sel.limit = c
 		} else {
-			sel.limit = &v
+			sel.limit = v
 		}
 	}
 	if p.acceptKeyword("OFFSET") {
-		v, err := p.parseIntLiteral()
+		v, err := p.parseLimitValue()
 		if err != nil {
 			return nil, err
 		}
-		sel.offset = &v
+		sel.offset = v
 	}
 	return &statement{kind: kSelect, sel: sel}, nil
 }
 
+// isSelectLiteralStart 判断 SELECT 后是否直接跟字面量 (数字/字符串/NULL/?),
+// 即 "SELECT 值清单" 无 FROM 形态的开头。
+func isSelectLiteralStart(t token) bool {
+	switch t.kind {
+	case tkNumber, tkString, tkArg:
+		return true
+	case tkKeyword:
+		return t.text == "NULL"
+	}
+	return false
+}
+
+// parseLimitValue 解析 LIMIT/OFFSET 的量: 整数字面量或 ? 占位符 (GORM 的
+// Limit/Offset 走参数时会发出 "LIMIT ?"; 字面量路径保持兼容)。
+func (p *parser) parseLimitValue() (value, error) {
+	if p.peek().kind == tkArg {
+		p.next()
+		idx := p.argSeq
+		p.argSeq++
+		return argValue{idx: idx}, nil
+	}
+	t := p.next()
+	if t.kind == tkNumber {
+		if _, err := strconv.ParseInt(t.text, 10, 64); err == nil {
+			return litValue{raw: t.text}, nil
+		}
+	}
+	return nil, fmt.Errorf("期望整数或 ? 占位符, 实际是 %q", t.text)
+}
+
 func isAggFn(w string) bool {
 	switch w {
 	case "COUNT", "SUM", "MIN", "MAX", "AVG":
@@ -1083,6 +1153,12 @@ func (p *parser) parseInsert() (*statement, error) {
 			break
 		}
 	}
+	// VALUES 行组之后遇到 ON: UPSERT 语法 (PostgreSQL ON CONFLICT /
+	// MySQL ON DUPLICATE KEY UPDATE)。引擎不支持, 给明确指引而不是笼统的
+	// "语句末尾有多余内容"。
+	if p.peek().kind == tkKeyword && p.peek().text == "ON" {
+		return nil, fmt.Errorf("不支持 UPSERT (ON CONFLICT/ON DUPLICATE KEY UPDATE); 请改用 Updates 或拆成 查+插")
+	}
 	return &statement{kind: kInsert, ins: ins}, nil
 }