Parcourir la source

feat(driver): INNER/LEFT 等值 JOIN — 嵌套循环 + 右表索引点查

解析器: FROM 表 [AS] 别名 + 链式 JOIN/INNER JOIN/LEFT [OUTER] JOIN ON 等值
(反向写法解析期交换), 列清单/WHERE/ORDER BY 全面支持限定名 (t.col/t.*),
tokenizer 增加 '.' 分隔符。
执行器 (join.go): 左表全扫描(本表条件下推) → 逐行取 ON 左值对右表 Eq 索引
点查的嵌套循环; INNER 无匹配丢行 / LEFT 补全 NULL / 多匹配笛卡尔; 跨表条件
(列比较、跨表 OR/NOT) 作残余谓词在组合行上过滤; 投影(重名列 别名_列名 去重)
后内存排序再 LIMIT/OFFSET。歧义列/非等值 ON 明确报错, JOIN+聚合暂不支持。
新增 8 个用例 (race 通过)。
yl32940 il y a 3 semaines
Parent
commit
d5aa269e42
4 fichiers modifiés avec 1598 ajouts et 50 suppressions
  1. 42 26
      DB/driver/exec.go
  2. 998 0
      DB/driver/join.go
  3. 369 0
      DB/driver/join_test.go
  4. 189 24
      DB/driver/parser.go

+ 42 - 26
DB/driver/exec.go

@@ -446,40 +446,55 @@ func columnKeyIndicator(tbl *core.Table, target int) string {
 	}
 }
 
-// queryStatement 执行 SELECT。普通 SELECT 流式返回 (持锁至 rows.Close, 首行
-// 不必等全扫完, 内存 O(缓冲)); 聚合一次性跑完、立即放锁并返回物化单行。
+// queryStatement 执行 SELECT。普通单表 SELECT 流式返回 (持锁至 rows.Close, 首行
+// 不必等全扫完, 内存 O(缓冲)); 聚合一次性跑完、立即放锁并返回物化单行;
+// JOIN 查询 (join.go) 物化嵌套循环执行, 算完即放锁。
 // release 非 nil 表示锁由本次查询 (非事务) 获取, 任何提前返回错误都必须 release。
 func queryStatement(ctx context.Context, db *core.DB, sel *selectStmt, args []driver.NamedValue, release func()) (driver.Rows, error) {
-	tbl, err := findTable(db, sel.table)
-	if err != nil {
+	if len(sel.joins) > 0 {
+		return queryJoin(db, sel, args, release)
+	}
+	fail := func(err error) (driver.Rows, error) {
 		if release != nil {
 			release()
 		}
 		return nil, err
 	}
+	tbl, err := findTable(db, sel.from.name)
+	if err != nil {
+		return fail(err)
+	}
+	// 列引用统一允许限定名 (别名.列 / 表名.列): 单表路径校验限定词是本表后剥掉,
+	// 剥好的 where/orderBy/agg.col 放语句副本, 不动 AST (Prepare 的语句要复用)。
+	quals := []string{sel.from.alias, sel.from.name}
+	sel2 := *sel
+	if sel2.where, err = dequalifyExpr(sel.where, quals...); err != nil {
+		return fail(err)
+	}
+	sel2.orderBy = make([]orderSpec, len(sel.orderBy))
+	for i, o := range sel.orderBy {
+		if sel2.orderBy[i].col, err = dequalifyColRef(o.col, quals...); err != nil {
+			return fail(err)
+		}
+		sel2.orderBy[i].desc = o.desc
+	}
 	// 聚合: SELECT COUNT(*)/SUM/... FROM t [WHERE ...] —— 一次性算完, 立即放锁
 	if sel.agg != nil {
-		r, err := queryAggregate(tbl, sel, args)
+		agg := *sel.agg
+		if agg.col, err = dequalifyColRef(agg.col, quals...); err != nil {
+			return fail(err)
+		}
+		sel2.agg = &agg
+		r, err := queryAggregate(tbl, &sel2, args)
 		if release != nil {
 			release()
 		}
 		return r, err
 	}
-	// 列清单: * = 全部活跃列; 否则按请求列
-	var colNames []string
-	if len(sel.columns) == 0 {
-		colNames = activeColumnNames(tbl)
-	} else {
-		colNames = make([]string, 0, len(sel.columns))
-		for _, c := range sel.columns {
-			if findField(tbl, c) == nil {
-				if release != nil {
-					release()
-				}
-				return nil, fmt.Errorf("%w: %q", core.ErrFieldNotFound, c)
-			}
-			colNames = append(colNames, c)
-		}
+	// 列清单: * / t.* = 全部活跃列; 否则按请求列
+	colNames, err := selectColsSingle(tbl, &sel2, quals...)
+	if err != nil {
+		return fail(err)
 	}
 	metas := make([]colMeta, len(colNames))
 	for i, cn := range colNames {
@@ -488,17 +503,14 @@ func queryStatement(ctx context.Context, db *core.DB, sel *selectStmt, args []dr
 
 	// 不在此 Select(): q.Scan 回调给的是未投影完整行, worker 按 colNames 自投影。
 	q := tbl.Query()
-	cond, err := translateWhere(tbl, sel.where, args)
+	cond, err := translateWhere(tbl, sel2.where, args)
 	if err != nil {
-		if release != nil {
-			release()
-		}
-		return nil, err
+		return fail(err)
 	}
 	if cond != nil {
 		q = q.Where(cond)
 	}
-	for _, o := range sel.orderBy {
+	for _, o := range sel2.orderBy {
 		q = q.OrderBy(o.col, o.desc)
 	}
 	if sel.limit != nil {
@@ -743,6 +755,10 @@ func translateWhere(tbl *core.Table, e expr, args []driver.NamedValue) (core.Con
 			return core.Le(t.col, v, fi.FieldType), nil
 		}
 		return nil, fmt.Errorf("不支持的比较符 %q", t.op)
+	case *colCmpExpr:
+		// 列与列比较: 引擎 Condition 只支持列与值, 仅 JOIN 查询把它当残余谓词在
+		// 组合行上求值; 单表/UPDATE/DELETE 走到这里说明用法越界, 明确报错。
+		return nil, fmt.Errorf("列与列的比较 (%s %s %s) 仅在多表 JOIN 查询中支持", t.left, t.op, t.right)
 	case *inExpr:
 		fi := findField(tbl, t.col)
 		if fi == nil {

+ 998 - 0
DB/driver/join.go

@@ -0,0 +1,998 @@
+package vrtxsql
+
+// JOIN 查询执行: 多表嵌套循环 + 右表索引点查, 结果物化后立即放锁。
+// 仅 SELECT ... FROM t1 JOIN t2 ON ... 走本路径 (单表查询仍走流式路径,
+// 见 exec.go queryStatement)。
+//
+// 执行策略:
+//   - 左表全扫描 (本表 WHERE 条件下推), 每行存 别名(小写) → 列(小写) → driver.Value;
+//   - 逐 JOIN: 取组合行里 ON 左列的值, 对右表做索引点查
+//     (tbl.Query().Where(core.Eq(...)) + 右表本表条件), 命中行做笛卡尔组合;
+//     INNER 无匹配丢行, LEFT 无匹配时右表列全 NULL 保留行;
+//   - WHERE 按列归属拆分: 叶子条件 (AND 结构) 下推到所属表, 跨表 OR/NOT/列比较
+//     作为残余谓词在组合行上用 Go 值比较过滤 (= != < <= > >=);
+//   - 投影 (*/t.*/t.col/col 混用, 重名列用 别名_列名 去重) → ORDER BY 内存
+//     稳定排序 (NULL 视为最小) → LIMIT/OFFSET, 返回物化 *rows。
+
+import (
+	"database/sql/driver"
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// =============================================================================
+// 限定名列引用的解析辅助 (单表路径解限定 + JOIN 路径归属判定共用)
+// =============================================================================
+
+// splitQualCol 把列引用拆成 (限定词, 列名); 无 '.' 时 qual 为空。
+func splitQualCol(ref string) (qual, col string) {
+	if i := strings.IndexByte(ref, '.'); i >= 0 {
+		return ref[:i], ref[i+1:]
+	}
+	return "", ref
+}
+
+// dequalifyColRef 校验并剥掉列引用的限定词 ("t.col" → "col"); 非限定名原样返回。
+func dequalifyColRef(ref string, quals ...string) (string, error) {
+	qual, col := splitQualCol(ref)
+	if qual == "" {
+		return ref, nil
+	}
+	for _, q := range quals {
+		if strings.EqualFold(q, qual) {
+			return col, nil
+		}
+	}
+	return "", fmt.Errorf("列限定 %q 与 FROM 表 (%s) 不匹配", qual, strings.Join(quals, "/"))
+}
+
+// dequalifyExpr 剥掉表达式里所有列引用的限定词 (单表 SELECT 用; 值原样保留)。
+func dequalifyExpr(e expr, quals ...string) (expr, error) {
+	if e == nil {
+		return nil, nil
+	}
+	switch t := e.(type) {
+	case *andExpr:
+		l, err := dequalifyExpr(t.l, quals...)
+		if err != nil {
+			return nil, err
+		}
+		r, err := dequalifyExpr(t.r, quals...)
+		if err != nil {
+			return nil, err
+		}
+		return &andExpr{l: l, r: r}, nil
+	case *orExpr:
+		l, err := dequalifyExpr(t.l, quals...)
+		if err != nil {
+			return nil, err
+		}
+		r, err := dequalifyExpr(t.r, quals...)
+		if err != nil {
+			return nil, err
+		}
+		return &orExpr{l: l, r: r}, nil
+	case *notExpr:
+		inner, err := dequalifyExpr(t.e, quals...)
+		if err != nil {
+			return nil, err
+		}
+		return &notExpr{e: inner}, nil
+	case *cmpExpr:
+		col, err := dequalifyColRef(t.col, quals...)
+		if err != nil {
+			return nil, err
+		}
+		return &cmpExpr{col: col, op: t.op, val: t.val}, nil
+	case *colCmpExpr:
+		l, err := dequalifyColRef(t.left, quals...)
+		if err != nil {
+			return nil, err
+		}
+		r, err := dequalifyColRef(t.right, quals...)
+		if err != nil {
+			return nil, err
+		}
+		return &colCmpExpr{left: l, op: t.op, right: r}, nil
+	case *inExpr:
+		col, err := dequalifyColRef(t.col, quals...)
+		if err != nil {
+			return nil, err
+		}
+		return &inExpr{col: col, vals: t.vals, neg: t.neg}, nil
+	case *isNullExpr:
+		col, err := dequalifyColRef(t.col, quals...)
+		if err != nil {
+			return nil, err
+		}
+		return &isNullExpr{col: col, neg: t.neg}, nil
+	case *likeExpr:
+		col, err := dequalifyColRef(t.col, quals...)
+		if err != nil {
+			return nil, err
+		}
+		return &likeExpr{col: col, pattern: t.pattern, neg: t.neg}, nil
+	}
+	return nil, fmt.Errorf("无法处理的 WHERE 表达式")
+}
+
+// selectColsSingle 单表 SELECT 的列清单展开: 空 / "*" / "t.*" → 全部活跃列;
+// "t.col" → 校验限定词后取列; 列存在性校验与旧行为一致 (ErrFieldNotFound)。
+func selectColsSingle(tbl *core.Table, sel *selectStmt, quals ...string) ([]string, error) {
+	if len(sel.columns) == 0 {
+		return activeColumnNames(tbl), nil
+	}
+	out := make([]string, 0, len(sel.columns))
+	for _, c := range sel.columns {
+		if c == "*" {
+			out = append(out, activeColumnNames(tbl)...)
+			continue
+		}
+		if strings.HasSuffix(c, ".*") {
+			if _, err := dequalifyColRef(c, quals...); err != nil {
+				return nil, err
+			}
+			out = append(out, activeColumnNames(tbl)...)
+			continue
+		}
+		col, err := dequalifyColRef(c, quals...)
+		if err != nil {
+			return nil, err
+		}
+		if findField(tbl, col) == nil {
+			return nil, fmt.Errorf("%w: %q", core.ErrFieldNotFound, c)
+		}
+		out = append(out, col)
+	}
+	return out, nil
+}
+
+// =============================================================================
+// JOIN 执行
+// =============================================================================
+
+// joinTable 参与 JOIN 的一张表: 引擎表 + 归一化别名 + 活跃列。
+type joinTable struct {
+	idx   int
+	name  string
+	alias string // 限定名归一化结果 (alias 缺省 = 表名), 比较时大小写不敏感
+	tbl   *core.Table
+	cols  []string                   // 活跃列名 (原始大小写, 投影展示用)
+	ft    map[string]types.FieldType // 小写列名 → 列类型 (typed 值转换用)
+}
+
+func buildJoinTable(idx int, ref tableRef, tbl *core.Table) *joinTable {
+	alias := ref.alias
+	if alias == "" {
+		alias = ref.name
+	}
+	jt := &joinTable{idx: idx, name: ref.name, alias: alias, tbl: tbl, ft: map[string]types.FieldType{}}
+	for i := range tbl.Field {
+		f := &tbl.Field[i]
+		if f.IsDropped() {
+			continue
+		}
+		cn := string(f.FieldName)
+		jt.cols = append(jt.cols, cn)
+		jt.ft[strings.ToLower(cn)] = f.FieldType
+	}
+	return jt
+}
+
+// joinRow 组合行: 表别名(小写) → 列名(小写) → driver.Value (未匹配侧为 NULL)。
+type joinRow map[string]map[string]driver.Value
+
+func (r joinRow) get(jt *joinTable, col string) driver.Value {
+	return r[strings.ToLower(jt.alias)][strings.ToLower(col)]
+}
+
+func cloneJoinRow(r joinRow) joinRow {
+	nr := make(joinRow, len(r)+1)
+	for k, v := range r {
+		nr[k] = v
+	}
+	return nr
+}
+
+// nullJoinRow 右表无匹配时 (LEFT JOIN) 的全 NULL 行。
+func nullJoinRow(jt *joinTable) map[string]driver.Value {
+	m := make(map[string]driver.Value, len(jt.cols))
+	for _, cn := range jt.cols {
+		m[strings.ToLower(cn)] = nil
+	}
+	return m
+}
+
+// findQual 按限定词找表 (别名或表名, 大小写不敏感)。
+func findQual(ts []*joinTable, qual string) *joinTable {
+	for _, jt := range ts {
+		if strings.EqualFold(jt.alias, qual) || strings.EqualFold(jt.name, qual) {
+			return jt
+		}
+	}
+	return nil
+}
+
+// ownerOfCol 解析列引用归属的表: 限定名直查; 非限定名要求在恰好一张表上存在
+// (存在于多张报"列不明确", 没有报列不存在)。返回归属表与剥掉限定后的列名。
+func ownerOfCol(ts []*joinTable, ref string) (*joinTable, string, error) {
+	qual, col := splitQualCol(ref)
+	if qual != "" {
+		jt := findQual(ts, qual)
+		if jt == nil {
+			return nil, "", fmt.Errorf("未知的表限定 %q (列 %q)", qual, ref)
+		}
+		if findField(jt.tbl, col) == nil {
+			return nil, "", fmt.Errorf("%w: %q", core.ErrFieldNotFound, ref)
+		}
+		return jt, col, nil
+	}
+	var hit *joinTable
+	for _, jt := range ts {
+		if findField(jt.tbl, col) != nil {
+			if hit != nil {
+				return nil, "", fmt.Errorf("列 %q 不明确 (同时存在于 %q 和 %q)", col, hit.alias, jt.alias)
+			}
+			hit = jt
+		}
+	}
+	if hit == nil {
+		return nil, "", fmt.Errorf("%w: %q", core.ErrFieldNotFound, ref)
+	}
+	return hit, col, nil
+}
+
+// scanJoinTable 扫一张表 (应用下推条件), 每行 → 小写列名 → driver.Value。
+func scanJoinTable(jt *joinTable, cond core.Condition) ([]map[string]driver.Value, error) {
+	q := jt.tbl.Query()
+	if cond != nil {
+		q = q.Where(cond)
+	}
+	var out []map[string]driver.Value
+	var convErr error
+	err := q.Scan(func(full []types.FieldValue) bool {
+		m := make(map[string]driver.Value, len(full))
+		for _, fv := range full {
+			key := strings.ToLower(string(fv.FieldName))
+			dv, err := fieldValueToDriverTyped(jt.ft[key], fv.Value)
+			if err != nil {
+				convErr = err
+				return false
+			}
+			m[key] = joinTypedValue(jt.ft[key], dv)
+		}
+		out = append(out, m)
+		return true
+	})
+	if convErr != nil {
+		return nil, convErr
+	}
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// joinTypedValue 把整型/浮点列的文本化值归一回数值: fieldValueToDriver 对
+// int32 等小整型走 fmt.Sprint 兜底成 []byte, 残余谓词/排序会因此变成字符串
+// 比较 ("100" < "25"), 这里按列类型归一, 与 BIGINT 的输出形态 (int64) 对齐。
+func joinTypedValue(ft types.FieldType, v driver.Value) driver.Value {
+	if v == nil {
+		return nil
+	}
+	var s string
+	switch t := v.(type) {
+	case []byte:
+		s = string(t)
+	case string:
+		s = t
+	default:
+		return v
+	}
+	if isIntType(ft) {
+		if n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64); err == nil {
+			return n
+		}
+	}
+	switch ft {
+	case types.FieldType_Float64, types.FieldType_Float32:
+		if f, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
+			return f
+		}
+	}
+	return v
+}
+
+// =============================================================================
+// WHERE 按归属拆分: 下推条件 + 残余谓词
+// =============================================================================
+
+// joinWherePlan WHERE 拆分结果: perTable[i] 下推到第 i 张表扫描的 AND 条件,
+// residual 为不可下推的表达式 (跨表 OR/NOT、列与列比较), 组合行上过滤。
+type joinWherePlan struct {
+	perTable []core.Condition
+	residual expr
+}
+
+func planJoinWhere(ts []*joinTable, where expr, args []driver.NamedValue) (*joinWherePlan, error) {
+	plan := &joinWherePlan{perTable: make([]core.Condition, len(ts))}
+	if where == nil {
+		return plan, nil
+	}
+	if err := splitJoinWhere(ts, where, args, plan); err != nil {
+		return nil, err
+	}
+	return plan, nil
+}
+
+// splitJoinWhere 递归拆分 AND 结构; 叶子条件按列归属下推, 其余整体留作残余。
+func splitJoinWhere(ts []*joinTable, e expr, args []driver.NamedValue, plan *joinWherePlan) error {
+	switch t := e.(type) {
+	case *andExpr:
+		if err := splitJoinWhere(ts, t.l, args, plan); err != nil {
+			return err
+		}
+		return splitJoinWhere(ts, t.r, args, plan)
+	case *orExpr, *notExpr:
+		// 结构条件: 整棵子树只涉及一张表仍可下推该表, 跨表则留作残余谓词
+		owners := map[*joinTable]bool{}
+		if err := exprOwners(ts, e, owners); err != nil {
+			return err
+		}
+		if len(owners) == 1 {
+			for jt := range owners {
+				return pushJoinWhere(jt, e, args, plan)
+			}
+		}
+		plan.residual = andResidual(plan.residual, e)
+		return nil
+	case *colCmpExpr:
+		// 列与列比较: 引擎 Condition 只支持列与值, 一律留作残余 (列归属校验在此做)
+		if _, _, err := ownerOfCol(ts, t.left); err != nil {
+			return err
+		}
+		if _, _, err := ownerOfCol(ts, t.right); err != nil {
+			return err
+		}
+		plan.residual = andResidual(plan.residual, e)
+		return nil
+	case *cmpExpr:
+		return pushJoinLeaf(ts, t.col, e, args, plan)
+	case *inExpr:
+		return pushJoinLeaf(ts, t.col, e, args, plan)
+	case *isNullExpr:
+		return pushJoinLeaf(ts, t.col, e, args, plan)
+	case *likeExpr:
+		return pushJoinLeaf(ts, t.col, e, args, plan)
+	}
+	return fmt.Errorf("无法拆分的 WHERE 表达式")
+}
+
+// pushJoinLeaf 叶子条件按列归属下推到对应表。
+func pushJoinLeaf(ts []*joinTable, ref string, e expr, args []driver.NamedValue, plan *joinWherePlan) error {
+	jt, _, err := ownerOfCol(ts, ref)
+	if err != nil {
+		return err
+	}
+	return pushJoinWhere(jt, e, args, plan)
+}
+
+// pushJoinWhere 把表达式翻译后 AND 进对应表的下推条件 (先剥限定词再走标准翻译)。
+func pushJoinWhere(jt *joinTable, e expr, args []driver.NamedValue, plan *joinWherePlan) error {
+	dq, err := dequalifyExpr(e, jt.alias, jt.name)
+	if err != nil {
+		return err
+	}
+	cond, err := translateWhere(jt.tbl, dq, args)
+	if err != nil {
+		return err
+	}
+	if cond == nil {
+		return nil
+	}
+	if plan.perTable[jt.idx] == nil {
+		plan.perTable[jt.idx] = cond
+	} else {
+		plan.perTable[jt.idx] = core.And(plan.perTable[jt.idx], cond)
+	}
+	return nil
+}
+
+// andResidual 把不可下推的条件用 AND 串起来。
+func andResidual(a, b expr) expr {
+	if a == nil {
+		return b
+	}
+	return &andExpr{l: a, r: b}
+}
+
+// exprOwners 收集表达式引用的全部表 (按指针去重); 列归属解析错误直接透出。
+func exprOwners(ts []*joinTable, e expr, owners map[*joinTable]bool) error {
+	mark := func(ref string) error {
+		jt, _, err := ownerOfCol(ts, ref)
+		if err != nil {
+			return err
+		}
+		owners[jt] = true
+		return nil
+	}
+	switch t := e.(type) {
+	case *andExpr:
+		if err := exprOwners(ts, t.l, owners); err != nil {
+			return err
+		}
+		return exprOwners(ts, t.r, owners)
+	case *orExpr:
+		if err := exprOwners(ts, t.l, owners); err != nil {
+			return err
+		}
+		return exprOwners(ts, t.r, owners)
+	case *notExpr:
+		return exprOwners(ts, t.e, owners)
+	case *cmpExpr:
+		return mark(t.col)
+	case *colCmpExpr:
+		if err := mark(t.left); err != nil {
+			return err
+		}
+		return mark(t.right)
+	case *inExpr:
+		return mark(t.col)
+	case *isNullExpr:
+		return mark(t.col)
+	case *likeExpr:
+		return mark(t.col)
+	}
+	return nil
+}
+
+// =============================================================================
+// 残余谓词求值: 组合行上的 Go 值比较
+// =============================================================================
+
+// evalJoinExpr 在组合行上求值残余谓词 (跨表 WHERE 条件)。
+func evalJoinExpr(e expr, ts []*joinTable, r joinRow, args []driver.NamedValue) (bool, error) {
+	switch t := e.(type) {
+	case *andExpr:
+		l, err := evalJoinExpr(t.l, ts, r, args)
+		if err != nil || !l {
+			return l, err
+		}
+		return evalJoinExpr(t.r, ts, r, args)
+	case *orExpr:
+		l, err := evalJoinExpr(t.l, ts, r, args)
+		if err != nil || l {
+			return l, err
+		}
+		return evalJoinExpr(t.r, ts, r, args)
+	case *notExpr:
+		v, err := evalJoinExpr(t.e, ts, r, args)
+		if err != nil {
+			return false, err
+		}
+		return !v, nil
+	case *cmpExpr:
+		jt, col, err := ownerOfCol(ts, t.col)
+		if err != nil {
+			return false, err
+		}
+		fi := findField(jt.tbl, col)
+		raw, err := resolveValue(fi, t.val, args)
+		if err != nil {
+			return false, err
+		}
+		rv, err := fieldValueToDriverTyped(fi.FieldType, raw)
+		if err != nil {
+			return false, err
+		}
+		return compareOp(r.get(jt, col), rv, t.op), nil
+	case *colCmpExpr:
+		ljt, lcol, err := ownerOfCol(ts, t.left)
+		if err != nil {
+			return false, err
+		}
+		rjt, rcol, err := ownerOfCol(ts, t.right)
+		if err != nil {
+			return false, err
+		}
+		return compareOp(r.get(ljt, lcol), r.get(rjt, rcol), t.op), nil
+	case *inExpr:
+		jt, col, err := ownerOfCol(ts, t.col)
+		if err != nil {
+			return false, err
+		}
+		fi := findField(jt.tbl, col)
+		lv := r.get(jt, col)
+		hit := false
+		for _, tv := range t.vals {
+			raw, err := resolveValue(fi, tv, args)
+			if err != nil {
+				return false, err
+			}
+			ev, err := fieldValueToDriverTyped(fi.FieldType, raw)
+			if err != nil {
+				return false, err
+			}
+			if c, ok := compareDriverValues(lv, ev); ok && c == 0 {
+				hit = true
+				break
+			}
+		}
+		return hit != t.neg, nil
+	case *isNullExpr:
+		jt, col, err := ownerOfCol(ts, t.col)
+		if err != nil {
+			return false, err
+		}
+		return (r.get(jt, col) == nil) != t.neg, nil
+	case *likeExpr:
+		jt, col, err := ownerOfCol(ts, t.col)
+		if err != nil {
+			return false, err
+		}
+		pat, err := resolveLikePattern(t.pattern, args)
+		if err != nil {
+			return false, err
+		}
+		s, ok := toCmpString(r.get(jt, col))
+		if !ok {
+			// NULL / 非文本: LIKE 不匹配 (NOT LIKE 相应为真)
+			return t.neg, nil
+		}
+		return likeMatch(s, pat) != t.neg, nil
+	}
+	return false, fmt.Errorf("无法求值的 WHERE 表达式")
+}
+
+// compareOp 按比较符求值; 类型不可比视为不匹配。
+func compareOp(l, r driver.Value, op string) bool {
+	c, ok := compareDriverValues(l, r)
+	if !ok {
+		return false
+	}
+	switch op {
+	case "=":
+		return c == 0
+	case "!=":
+		return c != 0
+	case "<":
+		return c < 0
+	case "<=":
+		return c <= 0
+	case ">":
+		return c > 0
+	case ">=":
+		return c >= 0
+	}
+	return false
+}
+
+// compareDriverValues 归一化比较两个 driver.Value, 返回 -1/0/1; 类型不可比返回
+// ok=false。NULL 视为最小 (两 NULL 相等); 数值按数值比, 文本按字典序比。
+func compareDriverValues(a, b driver.Value) (int, bool) {
+	if a == nil || b == nil {
+		switch {
+		case a == nil && b == nil:
+			return 0, true
+		case a == nil:
+			return -1, true
+		default:
+			return 1, true
+		}
+	}
+	if av, ok := toCmpFloat(a); ok {
+		bv, ok := toCmpFloat(b)
+		if !ok {
+			return 0, false
+		}
+		switch {
+		case av < bv:
+			return -1, true
+		case av > bv:
+			return 1, true
+		default:
+			return 0, true
+		}
+	}
+	if av, ok := toCmpString(a); ok {
+		bv, ok := toCmpString(b)
+		if !ok {
+			return 0, false
+		}
+		return strings.Compare(av, bv), true
+	}
+	if av, ok := a.(time.Time); ok {
+		bv, ok := b.(time.Time)
+		if !ok {
+			return 0, false
+		}
+		switch {
+		case av.Before(bv):
+			return -1, true
+		case av.After(bv):
+			return 1, true
+		default:
+			return 0, true
+		}
+	}
+	return 0, false
+}
+
+// toCmpFloat 数值型 driver.Value → float64 (bool 按 0/1)。
+func toCmpFloat(v driver.Value) (float64, bool) {
+	switch t := v.(type) {
+	case int64:
+		return float64(t), true
+	case float64:
+		return t, true
+	case bool:
+		if t {
+			return 1, true
+		}
+		return 0, true
+	}
+	return 0, false
+}
+
+// toCmpString 文本型 driver.Value → string。
+func toCmpString(v driver.Value) (string, bool) {
+	switch t := v.(type) {
+	case string:
+		return t, true
+	case []byte:
+		return string(t), true
+	}
+	return "", false
+}
+
+// likeMatch SQL LIKE 匹配: % 任意长度任意字符, _ 单字符, 其余原样。
+func likeMatch(s, pat string) bool {
+	sr, pr := []rune(s), []rune(pat)
+	si, pi := 0, 0
+	starP, starS := -1, 0
+	for si < len(sr) {
+		switch {
+		case pi < len(pr) && (pr[pi] == '_' || pr[pi] == sr[si]):
+			si++
+			pi++
+		case pi < len(pr) && pr[pi] == '%':
+			starP, starS = pi, si
+			pi++
+		case starP != -1:
+			pi = starP + 1
+			starS++
+			si = starS
+		default:
+			return false
+		}
+	}
+	for pi < len(pr) && pr[pi] == '%' {
+		pi++
+	}
+	return pi == len(pr)
+}
+
+// =============================================================================
+// ON 解析 / 投影 / 排序
+// =============================================================================
+
+// resolvedJoinOn 定好左右的 ON 条件: right 指向本 JOIN 的新表, left 指向其左侧
+// 已连接的表 (允许 "b.y = a.x" 写法, 解析期交换回来)。
+type resolvedJoinOn struct {
+	leftIdx  int
+	leftCol  string
+	rightIdx int
+	rightCol string
+}
+
+func resolveJoinOn(ts []*joinTable, newIdx int, jc *joinClause) (resolvedJoinOn, error) {
+	li, lcol, err := qualIndexCol(ts, jc.onL)
+	if err != nil {
+		return resolvedJoinOn{}, err
+	}
+	ri, rcol, err := qualIndexCol(ts, jc.onR)
+	if err != nil {
+		return resolvedJoinOn{}, err
+	}
+	if li == newIdx && ri < newIdx {
+		li, lcol, ri, rcol = ri, rcol, li, lcol
+	}
+	if ri != newIdx || li >= newIdx {
+		return resolvedJoinOn{}, fmt.Errorf("JOIN ON 条件 %q = %q 必须连接新表 %q 与其左侧的表", jc.onL, jc.onR, ts[newIdx].alias)
+	}
+	return resolvedJoinOn{leftIdx: li, leftCol: lcol, rightIdx: ri, rightCol: rcol}, nil
+}
+
+// qualIndexCol 解析 ON 里的限定列名为 (表下标, 列名); 必须是限定名且列存在。
+func qualIndexCol(ts []*joinTable, ref string) (int, string, error) {
+	qual, col := splitQualCol(ref)
+	if qual == "" {
+		return -1, "", fmt.Errorf("JOIN ON 列 %q 必须是限定名 (别名.列)", ref)
+	}
+	for i, jt := range ts {
+		if strings.EqualFold(jt.alias, qual) || strings.EqualFold(jt.name, qual) {
+			if findField(jt.tbl, col) == nil {
+				return -1, "", fmt.Errorf("%w: %q", core.ErrFieldNotFound, ref)
+			}
+			return i, col, nil
+		}
+	}
+	return -1, "", fmt.Errorf("未知的表限定 %q (列 %q)", qual, ref)
+}
+
+// joinProjCol 投影列: 归属表 + 小写列名 (行内取值) + 列元信息 + 展示名 (已去重)。
+type joinProjCol struct {
+	jt   *joinTable
+	col  string
+	fi   *types.FieldInfo
+	name string
+}
+
+// buildJoinProjection 展开 SELECT 清单 (*/t.*/t.col/col 混用); 展示名为列名,
+// 重名列用 "别名_列名" 去重。
+func buildJoinProjection(ts []*joinTable, columns []string) ([]joinProjCol, error) {
+	var out []joinProjCol
+	seen := map[string]bool{}
+	add := func(jt *joinTable, col string) error {
+		fi := findField(jt.tbl, col)
+		if fi == nil {
+			return fmt.Errorf("%w: %q", core.ErrFieldNotFound, col)
+		}
+		name := string(fi.FieldName)
+		if seen[strings.ToLower(name)] {
+			name = jt.alias + "_" + name
+			for seen[strings.ToLower(name)] {
+				name += "_"
+			}
+		}
+		seen[strings.ToLower(name)] = true
+		out = append(out, joinProjCol{jt: jt, col: strings.ToLower(col), fi: fi, name: name})
+		return nil
+	}
+	expand := func(jt *joinTable) error {
+		for _, cn := range jt.cols {
+			if err := add(jt, cn); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+	expandAll := func() error {
+		for _, jt := range ts {
+			if err := expand(jt); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+	if len(columns) == 0 {
+		return out, expandAll()
+	}
+	for _, c := range columns {
+		switch {
+		case c == "*":
+			if err := expandAll(); err != nil {
+				return nil, err
+			}
+		case strings.HasSuffix(c, ".*"):
+			jt := findQual(ts, c[:len(c)-2])
+			if jt == nil {
+				return nil, fmt.Errorf("未知的表限定 %q (列 %q)", c[:len(c)-2], c)
+			}
+			if err := expand(jt); err != nil {
+				return nil, err
+			}
+		default:
+			jt, col, err := ownerOfCol(ts, c) // 限定名/非限定名统一走归属解析
+			if err != nil {
+				return nil, err
+			}
+			if err := add(jt, col); err != nil {
+				return nil, err
+			}
+		}
+	}
+	return out, nil
+}
+
+// joinOrderKey 排序键: 归属表 + 列 + 方向。
+type joinOrderKey struct {
+	jt   *joinTable
+	col  string
+	desc bool
+}
+
+// resolveJoinOrderKeys 解析 ORDER BY 键 (限定名直查, 非限定名按唯一归属)。
+func resolveJoinOrderKeys(ts []*joinTable, specs []orderSpec) ([]joinOrderKey, error) {
+	keys := make([]joinOrderKey, len(specs))
+	for i, o := range specs {
+		jt, col, err := ownerOfCol(ts, o.col)
+		if err != nil {
+			return nil, err
+		}
+		keys[i] = joinOrderKey{jt: jt, col: strings.ToLower(col), desc: o.desc}
+	}
+	return keys, nil
+}
+
+// =============================================================================
+// queryJoin: JOIN 查询主流程
+// =============================================================================
+
+func queryJoin(db *core.DB, sel *selectStmt, args []driver.NamedValue, release func()) (driver.Rows, error) {
+	if release != nil {
+		defer release() // 物化结果集, 算完即放锁
+	}
+	if sel.agg != nil {
+		return nil, fmt.Errorf("JOIN 与聚合函数暂不支持同时使用")
+	}
+
+	// a. 解析所有表并归一化别名 (别名冲突提前报错)
+	refs := make([]tableRef, 0, len(sel.joins)+1)
+	refs = append(refs, sel.from)
+	for _, j := range sel.joins {
+		refs = append(refs, j.table)
+	}
+	ts := make([]*joinTable, len(refs))
+	seenAlias := map[string]bool{}
+	for i, ref := range refs {
+		tbl, err := findTable(db, ref.name)
+		if err != nil {
+			return nil, err
+		}
+		ts[i] = buildJoinTable(i, ref, tbl)
+		key := strings.ToLower(ts[i].alias)
+		if seenAlias[key] {
+			return nil, fmt.Errorf("JOIN 表别名 %q 重复", ts[i].alias)
+		}
+		seenAlias[key] = true
+	}
+
+	// b. WHERE 按归属拆分: 单表条件下推, 跨表条件留作残余谓词
+	plan, err := planJoinWhere(ts, sel.where, args)
+	if err != nil {
+		return nil, err
+	}
+
+	// c. 左表扫描 (本表条件下推), 每行包一层别名空间
+	leftRows, err := scanJoinTable(ts[0], plan.perTable[0])
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]joinRow, 0, len(leftRows))
+	for _, m := range leftRows {
+		rows = append(rows, joinRow{strings.ToLower(ts[0].alias): m})
+	}
+
+	// d. 逐 JOIN: 左行 ON 列值 → 右表索引点查 (+右表条件下推), 组合笛卡尔行
+	for i := range sel.joins {
+		jc := &sel.joins[i]
+		jt := ts[i+1]
+		on, err := resolveJoinOn(ts, i+1, jc)
+		if err != nil {
+			return nil, err
+		}
+		onFi := findField(jt.tbl, on.rightCol)
+		next := make([]joinRow, 0, len(rows))
+		for _, lr := range rows {
+			onVal := lr.get(ts[on.leftIdx], on.leftCol)
+			var matched []map[string]driver.Value
+			if onVal != nil {
+				// NULL 与任何值都不相等, 右表点查直接跳过 (INNER 丢行 / LEFT 补 NULL)
+				ev, err := driverToEngineValue(onFi, onVal)
+				if err != nil {
+					return nil, fmt.Errorf("JOIN ON 列 %q: %w", on.rightCol, err)
+				}
+				cond := core.Eq(on.rightCol, ev, onFi.FieldType)
+				if plan.perTable[i+1] != nil {
+					cond = core.And(cond, plan.perTable[i+1])
+				}
+				matched, err = scanJoinTable(jt, cond)
+				if err != nil {
+					return nil, err
+				}
+			}
+			if len(matched) == 0 {
+				if jc.left {
+					nr := cloneJoinRow(lr)
+					nr[strings.ToLower(jt.alias)] = nullJoinRow(jt)
+					next = append(next, nr)
+				}
+				continue // INNER JOIN: 无匹配丢行
+			}
+			for _, rm := range matched {
+				nr := cloneJoinRow(lr)
+				nr[strings.ToLower(jt.alias)] = rm
+				next = append(next, nr)
+			}
+		}
+		rows = next
+	}
+
+	// e. 残余谓词: 组合行上过滤 (跨表比较)
+	if plan.residual != nil {
+		kept := make([]joinRow, 0, len(rows))
+		for _, r := range rows {
+			ok, err := evalJoinExpr(plan.residual, ts, r, args)
+			if err != nil {
+				return nil, err
+			}
+			if ok {
+				kept = append(kept, r)
+			}
+		}
+		rows = kept
+	}
+
+	// f. 投影列清单 (*/t.*/t.col/col 混用, 重名列 别名_列名 去重)
+	proj, err := buildJoinProjection(ts, sel.columns)
+	if err != nil {
+		return nil, err
+	}
+
+	// g. ORDER BY: 投影前对组合行内存稳定排序 (NULL 视为最小), 再 LIMIT/OFFSET
+	if len(sel.orderBy) > 0 {
+		keys, err := resolveJoinOrderKeys(ts, sel.orderBy)
+		if err != nil {
+			return nil, err
+		}
+		sort.SliceStable(rows, func(x, y int) bool {
+			for _, k := range keys {
+				c, ok := compareDriverValues(rows[x].get(k.jt, k.col), rows[y].get(k.jt, k.col))
+				if !ok || c == 0 {
+					continue
+				}
+				if k.desc {
+					return c > 0
+				}
+				return c < 0
+			}
+			return false
+		})
+	}
+	if sel.offset != nil {
+		o := int(*sel.offset)
+		if o >= len(rows) {
+			rows = nil
+		} else {
+			rows = rows[o:]
+		}
+	}
+	if sel.limit != nil && int(*sel.limit) < len(rows) {
+		rows = rows[:int(*sel.limit)]
+	}
+
+	// 物化投影行 (DISTINCT 按投影值去重)
+	colNames := make([]string, len(proj))
+	metas := make([]colMeta, len(proj))
+	for i, pc := range proj {
+		colNames[i] = pc.name
+		metas[i] = colMetaFor(pc.fi)
+	}
+	data := make([][]driver.Value, 0, len(rows))
+	var seen map[string]bool
+	if sel.distinct {
+		seen = map[string]bool{}
+	}
+	for _, r := range rows {
+		vals := make([]driver.Value, len(proj))
+		for i, pc := range proj {
+			vals[i] = r.get(pc.jt, pc.col) // LEFT JOIN 未匹配侧 → NULL
+		}
+		if seen != nil {
+			key := distinctKey(vals)
+			if seen[key] {
+				continue
+			}
+			seen[key] = true
+		}
+		data = append(data, vals)
+	}
+	return materialRows(colNames, metas, data), nil
+}

+ 369 - 0
DB/driver/join_test.go

@@ -0,0 +1,369 @@
+package vrtxsql
+
+// JOIN 支持测试: INNER/LEFT [OUTER] JOIN、链式多表、限定名列、WHERE 下推与
+// 残余谓词 (跨表列比较 / 跨表 OR)、* 投影与重名列去重、歧义列/非法 ON 报错。
+// 全部走标准 database/sql 全链路, 数据与断言一一对应 join.go 的执行策略。
+
+import (
+	"database/sql"
+	"strings"
+	"testing"
+)
+
+// joinFixture 建 users/orders/refunds 三张表并灌入固定数据:
+//
+//	users:  1 alice 30 / 2 bob 25 / 3 carl 40
+//	orders: 10 ->u1 99.5 book / 11 ->u1 20 pen / 12 ->u2 7.5 cup
+//	refunds: 100 ->o10 broken / 101 ->o10 late
+func joinFixture(t *testing.T, db *sql.DB) {
+	t.Helper()
+	exec := func(q string, args ...interface{}) {
+		t.Helper()
+		if _, err := db.Exec(q, args...); err != nil {
+			t.Fatalf("%s: %v", q, err)
+		}
+	}
+	exec(`CREATE TABLE users (id BIGINT PRIMARY KEY, name VARCHAR(32) NOT NULL, age INT)`)
+	exec(`CREATE TABLE orders (id BIGINT PRIMARY KEY, user_id BIGINT, amount DOUBLE, title VARCHAR(32))`)
+	exec(`CREATE TABLE refunds (id BIGINT PRIMARY KEY, order_id BIGINT, reason VARCHAR(32))`)
+	exec(`INSERT INTO users (id, name, age) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)`,
+		1, "alice", 30, 2, "bob", 25, 3, "carl", 40)
+	exec(`INSERT INTO orders (id, user_id, amount, title) VALUES (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?)`,
+		10, 1, 99.5, "book", 11, 1, 20.0, "pen", 12, 2, 7.5, "cup")
+	exec(`INSERT INTO refunds (id, order_id, reason) VALUES (?, ?, ?), (?, ?, ?)`,
+		100, 10, "broken", 101, 10, "late")
+}
+
+type joinHit struct {
+	name, title string
+	amount      float64
+}
+
+// joinRows 执行 JOIN 查询并收集 (name, title, amount) 三元组。
+func joinRows(t *testing.T, db *sql.DB, query string, args ...interface{}) []joinHit {
+	t.Helper()
+	rows, err := db.Query(query, args...)
+	if err != nil {
+		t.Fatalf("query: %v", err)
+	}
+	defer rows.Close()
+	var got []joinHit
+	for rows.Next() {
+		var h joinHit
+		var name, title []byte
+		if err := rows.Scan(&name, &title, &h.amount); err != nil {
+			t.Fatalf("scan: %v", err)
+		}
+		h.name, h.title = string(name), string(title)
+		got = append(got, h)
+	}
+	if err := rows.Err(); err != nil {
+		t.Fatal(err)
+	}
+	return got
+}
+
+// TestJoinInner INNER JOIN 命中: users/orders 按 user_id 关联, 验证行数与列值。
+func TestJoinInner(t *testing.T) {
+	db := openTestDB(t)
+	joinFixture(t, db)
+
+	rows, err := db.Query(`SELECT u.name, o.title, o.amount FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.id ASC`)
+	if err != nil {
+		t.Fatalf("inner join: %v", err)
+	}
+	cols, err := rows.Columns()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Join(cols, ",") != "name,title,amount" {
+		t.Fatalf("列名=%v, 想要 [name title amount]", cols)
+	}
+	rows.Close()
+
+	got := joinRows(t, db, `SELECT u.name, o.title, o.amount FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.id ASC`)
+	want := []joinHit{{"alice", "book", 99.5}, {"alice", "pen", 20}, {"bob", "cup", 7.5}}
+	if len(got) != len(want) {
+		t.Fatalf("行数=%d, 想要 %d (%+v)", len(got), len(want), got)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Fatalf("行 %d = %+v, 想要 %+v", i, got[i], want[i])
+		}
+	}
+
+	// INNER JOIN 显式关键字 + 表名限定 (不写别名)
+	got = joinRows(t, db, `SELECT users.name, orders.title, orders.amount FROM users INNER JOIN orders ON users.id = orders.user_id ORDER BY orders.id ASC`)
+	if len(got) != len(want) {
+		t.Fatalf("INNER JOIN 行数=%d, 想要 %d (%+v)", len(got), len(want), got)
+	}
+}
+
+// TestJoinLeftNullRow LEFT OUTER JOIN 右表无匹配: 右列全 NULL 保留行。
+func TestJoinLeftNullRow(t *testing.T) {
+	db := openTestDB(t)
+	joinFixture(t, db)
+
+	rows, err := db.Query(`SELECT u.name, o.title FROM users u LEFT OUTER JOIN orders o ON u.id = o.user_id ORDER BY u.id ASC, o.id ASC`)
+	if err != nil {
+		t.Fatalf("left join: %v", err)
+	}
+	defer rows.Close()
+	type row struct {
+		name  string
+		title sql.NullString
+	}
+	var got []row
+	for rows.Next() {
+		var r row
+		var name []byte
+		if err := rows.Scan(&name, &r.title); err != nil {
+			t.Fatalf("scan: %v", err)
+		}
+		r.name = string(name)
+		got = append(got, r)
+	}
+	if err := rows.Err(); err != nil {
+		t.Fatal(err)
+	}
+	if len(got) != 4 {
+		t.Fatalf("行数=%d, 想要 4 (%+v)", len(got), got)
+	}
+	if got[3].name != "carl" || got[3].title.Valid {
+		t.Fatalf("carl 应为右表无匹配 (title NULL), 实际 %+v", got[3])
+	}
+	if got[0].title.String != "book" || got[1].title.String != "pen" || got[2].title.String != "cup" {
+		t.Fatalf("匹配行不对: %+v", got)
+	}
+}
+
+// TestJoinWhereOrderLimit JOIN + WHERE(限定名) + ORDER BY + LIMIT/OFFSET。
+func TestJoinWhereOrderLimit(t *testing.T) {
+	db := openTestDB(t)
+	joinFixture(t, db)
+
+	// u.age 下推 users 表, o.amount 下推 orders 表
+	got := joinRows(t, db,
+		`SELECT u.name, o.title, o.amount FROM users u JOIN orders o ON u.id = o.user_id
+		 WHERE u.age >= ? AND o.amount > ? ORDER BY o.amount DESC LIMIT 1`, 26, 50)
+	want := []joinHit{{"alice", "book", 99.5}}
+	if len(got) != 1 || got[0] != want[0] {
+		t.Fatalf("got %+v, 想要 %+v", got, want)
+	}
+
+	// 非限定列名 (唯一归属 age 在 users) + LIMIT offset,count 写法
+	// (age>=26 只剩 alice: ASC 序 [pen 20, book 99.5], LIMIT 1,1 = 跳过 1 行取 1 行)
+	got = joinRows(t, db,
+		`SELECT u.name, o.title, o.amount FROM users u JOIN orders o ON u.id = o.user_id
+		 WHERE age >= ? ORDER BY o.amount ASC LIMIT 1, 1`, 26)
+	if len(got) != 1 || got[0] != (joinHit{"alice", "book", 99.5}) {
+		t.Fatalf("LIMIT offset,count got %+v, 想要 [{alice book 99.5}]", got)
+	}
+
+	// OFFSET 关键字写法 (ASC 序 [cup 7.5, pen 20, book 99.5], 跳 1 行取 1 行)
+	got = joinRows(t, db,
+		`SELECT u.name, o.title, o.amount FROM users u JOIN orders o ON u.id = o.user_id
+		 ORDER BY o.amount ASC LIMIT 1 OFFSET 1`)
+	if len(got) != 1 || got[0] != (joinHit{"alice", "pen", 20}) {
+		t.Fatalf("OFFSET got %+v, 想要 [{alice pen 20}]", got)
+	}
+}
+
+// TestJoinThreeTables 3 表链式 JOIN: users → orders → refunds。
+func TestJoinThreeTables(t *testing.T) {
+	db := openTestDB(t)
+	joinFixture(t, db)
+
+	rows, err := db.Query(
+		`SELECT u.name, o.title, r.reason FROM users u
+		 JOIN orders o ON u.id = o.user_id
+		 JOIN refunds r ON o.id = r.order_id
+		 ORDER BY r.id ASC`)
+	if err != nil {
+		t.Fatalf("3 表 join: %v", err)
+	}
+	defer rows.Close()
+	var got [][2]string
+	for rows.Next() {
+		var name, title, reason []byte
+		if err := rows.Scan(&name, &title, &reason); err != nil {
+			t.Fatalf("scan: %v", err)
+		}
+		got = append(got, [2]string{string(name) + "/" + string(title), string(reason)})
+	}
+	if err := rows.Err(); err != nil {
+		t.Fatal(err)
+	}
+	want := [][2]string{{"alice/book", "broken"}, {"alice/book", "late"}}
+	if len(got) != len(want) {
+		t.Fatalf("行数=%d, 想要 %d (%+v)", len(got), len(want), got)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Fatalf("行 %d = %+v, 想要 %+v", i, got[i], want[i])
+		}
+	}
+}
+
+// TestJoinStarAndDedup * 投影跨表展开, 重名列 (id) 用 别名_列名 去重。
+func TestJoinStarAndDedup(t *testing.T) {
+	db := openTestDB(t)
+	joinFixture(t, db)
+
+	rs, err := db.Query(`SELECT * FROM users u JOIN orders o ON u.id = o.user_id WHERE u.id = 1 ORDER BY o.id ASC`)
+	if err != nil {
+		t.Fatalf("star join: %v", err)
+	}
+	defer rs.Close()
+	cols, err := rs.Columns()
+	if err != nil {
+		t.Fatal(err)
+	}
+	wantCols := "id,name,age,o_id,user_id,amount,title"
+	if strings.Join(cols, ",") != wantCols {
+		t.Fatalf("列=%v, 想要 %v", cols, wantCols)
+	}
+	n := 0
+	for rs.Next() {
+		vals := make([]interface{}, len(cols))
+		ptrs := make([]interface{}, len(cols))
+		for i := range vals {
+			ptrs[i] = &vals[i]
+		}
+		if err := rs.Scan(ptrs...); err != nil {
+			t.Fatalf("scan: %v", err)
+		}
+		if n == 0 {
+			// 第一行: alice 的 book 订单
+			if vals[0] != int64(1) || string(vals[1].([]byte)) != "alice" || vals[2] != int64(30) ||
+				vals[3] != int64(10) || vals[4] != int64(1) || vals[5] != float64(99.5) ||
+				string(vals[6].([]byte)) != "book" {
+				t.Fatalf("行值不对: %#v", vals)
+			}
+		}
+		n++
+	}
+	if err := rs.Err(); err != nil {
+		t.Fatal(err)
+	}
+	if n != 2 {
+		t.Fatalf("行数=%d, 想要 2", n)
+	}
+
+	// t.* 与限定列混用 (u.* = id,name,age 三列 + o.amount)
+	rs2, err := db.Query(`SELECT u.*, o.amount FROM users u JOIN orders o ON u.id = o.user_id WHERE o.id = 12`)
+	if err != nil {
+		t.Fatalf("t.* 混用: %v", err)
+	}
+	defer rs2.Close()
+	if !rs2.Next() {
+		t.Fatal("t.* 混用无行")
+	}
+	var id, age int64
+	var uname []byte
+	var amount float64
+	if err := rs2.Scan(&id, &uname, &age, &amount); err != nil {
+		t.Fatalf("scan: %v", err)
+	}
+	if id != 2 || string(uname) != "bob" || age != 25 || amount != 7.5 {
+		t.Fatalf("t.* 混用行 = %d,%s,%d,%v", id, uname, age, amount)
+	}
+}
+
+// TestJoinResidualPredicate 跨表条件作为残余谓词在组合行上过滤:
+// 列与列比较 (o.amount > u.age) 与跨表 OR (u.name = ? OR o.amount > ?)。
+func TestJoinResidualPredicate(t *testing.T) {
+	db := openTestDB(t)
+	joinFixture(t, db)
+
+	// 列比较: alice 99.5>30 命中, 20>30 / 7.5>25 不命中
+	got := joinRows(t, db,
+		`SELECT u.name, o.title, o.amount FROM users u JOIN orders o ON u.id = o.user_id
+		 WHERE o.amount > u.age ORDER BY o.id ASC`)
+	want := []joinHit{{"alice", "book", 99.5}}
+	if len(got) != len(want) || got[0] != want[0] {
+		t.Fatalf("列比较 got %+v, 想要 %+v", got, want)
+	}
+
+	// 跨表 OR (结构条件无法下推): alice 两行 + amount>50 的 alice/book
+	got = joinRows(t, db,
+		`SELECT u.name, o.title, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id
+		 WHERE u.name = ? OR o.amount > ? ORDER BY o.id ASC`, "alice", 50)
+	want = []joinHit{{"alice", "book", 99.5}, {"alice", "pen", 20}}
+	if len(got) != len(want) {
+		t.Fatalf("跨表 OR 行数=%d, 想要 %d (%+v)", len(got), len(want), got)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Fatalf("行 %d = %+v, 想要 %+v", i, got[i], want[i])
+		}
+	}
+}
+
+// TestJoinErrors 歧义列 / 非法 ON / 未知限定的明确报错。
+func TestJoinErrors(t *testing.T) {
+	db := openTestDB(t)
+	joinFixture(t, db)
+
+	// SELECT 非限定歧义列 (users/orders 都有 id)
+	if _, err := db.Query(`SELECT id FROM users u JOIN orders o ON u.id = o.user_id`); err == nil ||
+		!strings.Contains(err.Error(), "不明确") {
+		t.Fatalf("SELECT 歧义列应报 不明确, 实际 %v", err)
+	}
+	// WHERE 非限定歧义列 (age 只属 users, id 两表都有)
+	if _, err := db.Query(`SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE id > 10`); err == nil ||
+		!strings.Contains(err.Error(), "不明确") {
+		t.Fatalf("WHERE 歧义列应报 不明确, 实际 %v", err)
+	}
+	// ON 非等值
+	if _, err := db.Query(`SELECT * FROM users u JOIN orders o ON u.id > o.user_id`); err == nil ||
+		!strings.Contains(err.Error(), "JOIN ON 仅支持等值条件") {
+		t.Fatalf("ON 非等值应报错, 实际 %v", err)
+	}
+	// ON 未知限定
+	if _, err := db.Query(`SELECT * FROM users u JOIN orders o ON x.id = o.user_id`); err == nil {
+		t.Fatalf("ON 未知限定应报错")
+	}
+	// ON 列不存在
+	if _, err := db.Query(`SELECT * FROM users u JOIN orders o ON u.nope = o.user_id`); err == nil {
+		t.Fatalf("ON 未知列应报错")
+	}
+	// JOIN 与聚合同用
+	if _, err := db.Query(`SELECT COUNT(*) FROM users u JOIN orders o ON u.id = o.user_id`); err == nil {
+		t.Fatalf("JOIN+聚合应报错")
+	}
+}
+
+// TestJoinQualifiedSingleTable 单表查询的限定名列/t.* (兼容路径, 解限定后走标准翻译)。
+func TestJoinQualifiedSingleTable(t *testing.T) {
+	db := openTestDB(t)
+	joinFixture(t, db)
+
+	var name []byte
+	if err := db.QueryRow(`SELECT u.name FROM users u WHERE u.age > ? ORDER BY u.id LIMIT 1`, 26).Scan(&name); err != nil {
+		t.Fatalf("限定名 WHERE: %v", err)
+	}
+	if string(name) != "alice" {
+		t.Fatalf("name=%q, 想要 alice", name)
+	}
+	if err := db.QueryRow(`SELECT name FROM users AS u WHERE u.age >= ? ORDER BY u.id DESC LIMIT 1`, 26).Scan(&name); err != nil {
+		t.Fatalf("AS 别名 + 限定名: %v", err)
+	}
+	if string(name) != "carl" {
+		t.Fatalf("name=%q, 想要 carl", name)
+	}
+
+	// t.* 单表展开 + 聚合限定列
+	var age int64
+	if err := db.QueryRow(`SELECT COUNT(u.id) FROM users u WHERE u.age > ?`, 26).Scan(&age); err != nil {
+		t.Fatalf("聚合限定列: %v", err)
+	}
+	if age != 2 {
+		t.Fatalf("count=%d, 想要 2", age)
+	}
+
+	// 限定词不是本表 → 明确报错
+	if _, err := db.Query(`SELECT o.name FROM users u WHERE u.age > 1`); err == nil {
+		t.Fatalf("限定词不匹配应报错")
+	}
+}

+ 189 - 24
DB/driver/parser.go

@@ -5,7 +5,8 @@
 //
 // 解析器是刻意收窄的【ORM 子集翻译器】, 不是通用 SQL 引擎:
 //
-//	SELECT cols|* FROM t [WHERE ...] [ORDER BY c [ASC|DESC], ...] [LIMIT n] [OFFSET n]
+//	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]
 //	SELECT COUNT(*)|COUNT/SUM/MIN/MAX/AVG(col) FROM t [WHERE ...]          (无 GROUP BY)
 //	INSERT INTO t [(cols)] VALUES (...),(...)
 //	UPDATE t SET c=v,... [WHERE ...]
@@ -15,7 +16,9 @@
 //	DROP TABLE [IF EXISTS] t
 //
 //	WHERE 支持: = != <> < <= > >= AND OR NOT (..) IN (..) IS [NOT] NULL LIKE。
-//	不支持: JOIN/子查询/GROUP BY/HAVING/函数表达式/DEFAULT 非 NULL 值 —— 遇到
+//	JOIN 支持: 多表链式 [INNER] JOIN / LEFT [OUTER] JOIN, ON 仅等值 a.x = b.y;
+//	列引用允许限定名 (别名.列 / 表名.列), 跨表条件作为残余谓词在组合行上过滤。
+//	不支持: 子查询/GROUP BY/HAVING/函数表达式/DEFAULT 非 NULL 值 —— 遇到
 //	返回明确错误而不是静默做错。
 package vrtxsql
 
@@ -122,8 +125,9 @@ func max2(a, b int) int {
 }
 
 type selectStmt struct {
-	table    string
-	columns  []string // 空 = *
+	from     tableRef // FROM 表 (alias 缺省 = 表名, 限定列名归一化用)
+	joins    []joinClause
+	columns  []string // 空 = *; 元素可为 "*"(全表) / "t.*"(指定表全列) / 列名 / "t.列名"
 	distinct bool     // SELECT DISTINCT
 	agg      *aggSpec // 非空 = 单行聚合
 	where    expr
@@ -132,6 +136,20 @@ type selectStmt struct {
 	offset   *int64
 }
 
+// tableRef FROM/JOIN 里的表引用: 表名 + 别名 (AS 可省略)。
+type tableRef struct {
+	name  string
+	alias string
+}
+
+// joinClause 一个 JOIN 子句: [LEFT] JOIN 表 ON a.x = b.y (onL/onR 为限定列名)。
+type joinClause struct {
+	left  bool // LEFT [OUTER] JOIN
+	table tableRef
+	onL   string
+	onR   string
+}
+
 type aggSpec struct {
 	fn  string // COUNT / SUM / MIN / MAX / AVG
 	col string // COUNT(*) 时为空
@@ -239,6 +257,14 @@ type cmpExpr struct {
 	val value
 }
 
+// colCmpExpr 列与列比较 (a.x = b.y): 引擎 Condition 只支持列与值, 该表达式仅
+// 在 JOIN 查询里作为残余谓词在组合行上求值; 单表查询遇到会明确报错。
+type colCmpExpr struct {
+	left  string
+	right string
+	op    string // = != < <= > >=
+}
+
 type inExpr struct {
 	col  string
 	vals []value
@@ -256,13 +282,14 @@ type likeExpr struct {
 	neg     bool
 }
 
-func (*andExpr) isExpr()     {}
-func (*orExpr) isExpr()      {}
-func (*notExpr) isExpr()     {}
-func (*cmpExpr) isExpr()     {}
-func (*inExpr) isExpr()      {}
-func (*isNullExpr) isExpr()  {}
-func (*likeExpr) isExpr()    {}
+func (*andExpr) isExpr()    {}
+func (*orExpr) isExpr()     {}
+func (*notExpr) isExpr()    {}
+func (*cmpExpr) isExpr()    {}
+func (*colCmpExpr) isExpr() {}
+func (*inExpr) isExpr()     {}
+func (*isNullExpr) isExpr() {}
+func (*likeExpr) isExpr()   {}
 
 // ---- 值 ----
 
@@ -314,6 +341,7 @@ var keywords = map[string]bool{
 	"ALTER": true, "ADD": true, "COLUMN": true,
 	"SHOW": true, "DESCRIBE": true, "FULL": true, "TABLES": true, "COLUMNS": true,
 	"TRUNCATE": true, "DISTINCT": true,
+	"JOIN": true, "INNER": true, "LEFT": true, "OUTER": true,
 }
 
 func tokenize(sqlText string) ([]token, error) {
@@ -397,6 +425,10 @@ func tokenize(sqlText string) ([]token, error) {
 		case c == '*':
 			toks = append(toks, token{tkPunct, "*"})
 			i++
+		case c == '.':
+			// 限定名分隔符 (数字字面量里的 '.' 已由数字分支整体吃掉, 不受影响)
+			toks = append(toks, token{tkPunct, "."})
+			i++
 		case c == '=':
 			toks = append(toks, token{tkPunct, "="})
 			i++
@@ -578,10 +610,8 @@ func (p *parser) parseSelect() (*statement, error) {
 		sel.distinct = true
 	}
 
-	// SELECT 列表: * / 列名 / 聚合
-	if p.acceptPunct("*") {
-		// 纯 * 或 COUNT(*) 在聚合分支处理
-	} else if p.peek().kind == tkKeyword && isAggFn(p.peek().text) {
+	// SELECT 列表: * / t.* / 列名 / t.列名 (可混用); 聚合单行另走分支
+	if p.peek().kind == tkKeyword && isAggFn(p.peek().text) {
 		agg, err := p.parseAgg()
 		if err != nil {
 			return nil, err
@@ -589,11 +619,15 @@ func (p *parser) parseSelect() (*statement, error) {
 		sel.agg = agg
 	} else {
 		for {
-			col, err := p.expectIdent()
-			if err != nil {
-				return nil, err
+			if p.acceptPunct("*") {
+				sel.columns = append(sel.columns, "*")
+			} else {
+				col, err := p.parseSelectCol()
+				if err != nil {
+					return nil, err
+				}
+				sel.columns = append(sel.columns, col)
 			}
-			sel.columns = append(sel.columns, col)
 			// 容忍 "col AS alias" (alias 丢弃, 引擎列名以表为准)
 			if p.acceptKeyword("AS") {
 				if _, err := p.expectIdent(); err != nil {
@@ -609,11 +643,23 @@ func (p *parser) parseSelect() (*statement, error) {
 	if err := p.expectKeyword("FROM"); err != nil {
 		return nil, err
 	}
-	table, err := p.expectIdent()
+	from, err := p.parseTableRef()
 	if err != nil {
 		return nil, err
 	}
-	sel.table = table
+	sel.from = from
+
+	// JOIN 链: [INNER] JOIN / LEFT [OUTER] JOIN 表 ON a.x = b.y (可多个, 3 表以上)
+	for {
+		jc, ok, err := p.parseJoinClause()
+		if err != nil {
+			return nil, err
+		}
+		if !ok {
+			break
+		}
+		sel.joins = append(sel.joins, jc)
+	}
 
 	if p.acceptKeyword("WHERE") {
 		e, err := p.parseExpr()
@@ -630,7 +676,7 @@ func (p *parser) parseSelect() (*statement, error) {
 			return nil, err
 		}
 		for {
-			col, err := p.expectIdent()
+			col, err := p.parseColRef()
 			if err != nil {
 				return nil, err
 			}
@@ -690,7 +736,7 @@ func (p *parser) parseAgg() (*aggSpec, error) {
 	if fn == "COUNT" && p.acceptPunct("*") {
 		spec.col = ""
 	} else {
-		col, err := p.expectIdent()
+		col, err := p.parseColRef()
 		if err != nil {
 			return nil, err
 		}
@@ -713,6 +759,114 @@ func (p *parser) parseIntLiteral() (int64, error) {
 	return 0, fmt.Errorf("期望整数, 实际是 %q", t.text)
 }
 
+// parseColRef 解析列引用: 列名 或 限定列名 "t.col" (两个 ident 由 '.' 连, 解析层拼)。
+// 列引用统一存原文 ("t.col"), 执行期按 "." 拆分归属。
+func (p *parser) parseColRef() (string, error) {
+	col, err := p.expectIdent()
+	if err != nil {
+		return "", err
+	}
+	if p.acceptPunct(".") {
+		c2, err := p.expectIdent()
+		if err != nil {
+			return "", err
+		}
+		return col + "." + c2, nil
+	}
+	return col, nil
+}
+
+// parseSelectCol 解析 SELECT 清单列项: 列名 / t.列名 / t.* (返回 "t.*")。
+func (p *parser) parseSelectCol() (string, error) {
+	col, err := p.expectIdent()
+	if err != nil {
+		return "", err
+	}
+	if p.acceptPunct(".") {
+		if p.acceptPunct("*") {
+			return col + ".*", nil
+		}
+		c2, err := p.expectIdent()
+		if err != nil {
+			return "", err
+		}
+		return col + "." + c2, nil
+	}
+	return col, nil
+}
+
+// parseTableRef 解析 "表名 [AS] 别名"; 无别名时 alias 归一为表名 (限定列名归一化用)。
+func (p *parser) parseTableRef() (tableRef, error) {
+	name, err := p.expectIdent()
+	if err != nil {
+		return tableRef{}, err
+	}
+	ref := tableRef{name: name, alias: name}
+	if p.acceptKeyword("AS") {
+		alias, err := p.expectIdent()
+		if err != nil {
+			return tableRef{}, err
+		}
+		ref.alias = alias
+	} else if p.peek().kind == tkIdent {
+		ref.alias = p.next().text
+	}
+	return ref, nil
+}
+
+// parseJoinClause 尝试解析一个 JOIN 子句; 当前不是 JOIN 开头时返回 ok=false。
+// JOIN / INNER JOIN / LEFT JOIN / LEFT OUTER JOIN, ON 仅支持等值 a.x = b.y。
+func (p *parser) parseJoinClause() (joinClause, bool, error) {
+	var jc joinClause
+	switch {
+	case p.acceptKeyword("JOIN"):
+		// JOIN 是 INNER JOIN 的简写
+	case p.acceptKeyword("INNER"):
+		if err := p.expectKeyword("JOIN"); err != nil {
+			return jc, false, err
+		}
+	case p.acceptKeyword("LEFT"):
+		jc.left = true
+		p.acceptKeyword("OUTER")
+		if err := p.expectKeyword("JOIN"); err != nil {
+			return jc, false, err
+		}
+	default:
+		return jc, false, nil
+	}
+	ref, err := p.parseTableRef()
+	if err != nil {
+		return jc, false, err
+	}
+	jc.table = ref
+	if err := p.expectKeyword("ON"); err != nil {
+		return jc, false, err
+	}
+	onL, onR, err := p.parseJoinOn()
+	if err != nil {
+		return jc, false, err
+	}
+	jc.onL, jc.onR = onL, onR
+	return jc, true, nil
+}
+
+// parseJoinOn 解析 "a.x = b.y" 形式的等值连接条件, 两侧都必须是限定列名。
+func (p *parser) parseJoinOn() (string, string, error) {
+	l, err := p.parseColRef()
+	if err != nil {
+		return "", "", err
+	}
+	op := p.next()
+	if op.kind != tkPunct || op.text != "=" {
+		return "", "", fmt.Errorf("JOIN ON 仅支持等值条件, 实际是 %q", op.text)
+	}
+	r, err := p.parseColRef()
+	if err != nil {
+		return "", "", err
+	}
+	return l, r, nil
+}
+
 // parseExpr: OR → AND → NOT → 原子
 func (p *parser) parseExpr() (expr, error) { return p.parseOr() }
 
@@ -771,7 +925,7 @@ func (p *parser) parseAtom() (expr, error) {
 		}
 		return e, nil
 	}
-	col, err := p.expectIdent()
+	col, err := p.parseColRef()
 	if err != nil {
 		return nil, err
 	}
@@ -831,6 +985,17 @@ func (p *parser) parseAtom() (expr, error) {
 	default:
 		return nil, fmt.Errorf("不支持的比较符 %q", op.text)
 	}
+	// 右侧是列引用 → 列与列比较 (JOIN 残余谓词用); 否则按值解析
+	if p.peek().kind == tkIdent {
+		rcol, err := p.parseColRef()
+		if err != nil {
+			return nil, err
+		}
+		if op.text == "<>" {
+			op.text = "!="
+		}
+		return &colCmpExpr{left: col, op: op.text, right: rcol}, nil
+	}
 	v, err := p.parseValue()
 	if err != nil {
 		return nil, err