GORM中递归CTE必须用Raw SQL实现,因链式API不支持;需手动拼写WITH RECURSIVE,注意anchor与recursive分离、UNION ALL连接、字段类型匹配及Scan结构体严格对齐。

WITH 语句在 GORM 中必须用 Raw SQL 才能真正生效
GORM 的链式 API(如 Joins、Preload)不支持递归 CTE(Common Table Expression),哪怕你用 Session 或 Scopes 包裹,最终生成的 SQL 也不会把 WITH RECURSIVE 放到查询最前面。这是语法层级的限制:CTE 是整个查询的前置定义,而 GORM 的构建逻辑默认以 SELECT ... FROM 为起点。
所以,要写递归树查(比如查某个节点的所有子节点,含无限层级),必须放弃纯 ORM 写法,改用 Raw + 手动拼接。但别急着写死 SQL 字符串——GORM 提供了 Scan 和结构体映射能力,可以保留类型安全。
- 用
db.Raw("WITH RECURSIVE ...").Scan(&results)是唯一可靠路径 - CTE 名称不能和表名冲突;若主查询中引用了同名表,建议 CTE 显式起别名(如
tree AS (...)) - PostgreSQL 支持
WITH RECURSIVE,MySQL 8.0+ 也支持,但 SQLite 和旧版 MySQL 直接报错near "WITH",上线前务必确认数据库版本
递归 CTE 的 anchor + recursive 部分必须严格分离
常见错误是把初始查询(anchor)和递归查询(recursive term)混写,或者漏掉 UNION ALL 连接。GORM 不会帮你校验这部分逻辑,出错时只会返回空结果或 panic(比如 scan 类型不匹配),而不是明确提示 CTE 语法错误。
以查评论树为例(comments 表含 id、parent_id、content):
WITH RECURSIVE comment_tree AS ( -- anchor: 根评论(parent_id IS NULL 或 = 0) SELECT id, parent_id, content, 1 AS level FROM comments WHERE id = ? <p>UNION ALL</p><p>-- recursive: 找所有直接子评论 SELECT c.id, c.parent_id, c.content, ct.level + 1 FROM comments c INNER JOIN comment_tree ct ON c.parent_id = ct.id ) SELECT * FROM comment_tree ORDER BY level, id;
- 参数占位符
?在db.Raw(sql, id).Scan(&results)中会被正确绑定,不用手动字符串拼接 -
level字段必须显式声明类型(如INT),否则 PostgreSQL 可能推导为numeric,导致 Go struct 中Level intscan 失败 - 递归部分的
JOIN条件必须引用 CTE 别名(ct),不能写成comment_tree—— 某些数据库会报“relation not found”
分页不能套在 WITH 外层再 LIMIT OFFSET
直接对整个 CTE 查询加 LIMIT 10 OFFSET 20 看似合理,但会导致两个问题:一是递归可能被截断(只展开前 N 行,深层子节点丢失);二是分页不准(CTE 展开后行数 ≠ 原始树节点数,尤其当一个根节点有上百子节点时)。
正确做法是:先用 CTE 完整展开树结构,再用子查询或窗口函数控制层级/顺序,最后分页。更实用的是「按深度优先顺序编号」+「范围分页」:
WITH RECURSIVE comment_tree AS ( SELECT id, parent_id, content, 1 AS level, CAST(id AS TEXT) AS path FROM comments WHERE parent_id IS NULL AND id = ? <p>UNION ALL</p><p>SELECT c.id, c.parent_id, c.content, ct.level + 1, ct.path || '.' || c.id::TEXT FROM comments c INNER JOIN comment_tree ct ON c.parent_id = ct.id ), ordered AS ( SELECT *, ROW_NUMBER() OVER (ORDER BY path) AS rn FROM comment_tree ) SELECT id, parent_id, content, level FROM ordered WHERE rn BETWEEN ? AND ?;
-
path字段模拟 DFS 遍历序,保证父子紧邻,避免层级跳跃导致分页割裂 - 分页参数传入的是
start := offset + 1和end := offset + limit,不是原始OFFSET/LIMIT - 如果数据量极大(>10w 行),
ROW_NUMBER()会全表排序,此时应考虑加物化路径字段(lft/rgt)替代递归查询
GORM Scan 结构体字段必须与 SELECT 列完全对齐
CTE 查询出来的列名、顺序、类型,必须和 Go struct 字段一一对应,否则 Scan 会静默跳过字段或报 sql: expected 3 destination arguments 错误。GORM 不会像 Find 那样自动忽略不存在字段。
例如上面的 ordered 查询返回 id、parent_id、content、level、rn 五列,那么结构体就得写:
type CommentNode struct {
ID uint `gorm:"column:id"`
ParentID uint `gorm:"column:parent_id"`
Content string `gorm:"column:content"`
Level int `gorm:"column:level"`
RowNumber int `gorm:"column:rn"` // 必须存在,即使不用
}-
column:tag 不可省略,因为 CTE 中的别名(如ct.level)不会被自动映射为 struct 字段 - 如果 CTE 中用了表达式(如
COALESCE(parent_id, 0)),必须在 struct 中声明对应字段,且类型要匹配(此处是int,不是uint) - 别用
map[string]interface{}接收——虽然能跑通,但失去类型检查,后续处理容易 panic
实际用的时候,最常卡在 CTE 语法细节和 Scan 字段对不上这两处。递归本身不难,难的是让数据库执行计划不出偏差,再让 Go 把每列稳稳接住。


















