OOro

Nested Set 树形结构

使用官方 nestedset 扩展存储和查询层级数据。

extensions/nestedset 提供类型化树服务,用 ParentID_lft_rgtDepth 保存节点,并提供创建、移动、读取、删除、检查和重建方法。

安装

import "github.com/duxweb/oro/extensions/nestedset"

打开数据库时注册扩展:

db, err := oro.Open(oro.Config{
    Connections: map[string]oro.ConnectionConfig{
        "default": {Driver: sqlite.Open("app.db")},
    },
    Extensions: []oro.Extension{
        nestedset.Extension(),
    },
})

模型

直接嵌入 nestedset.NodeFields

type Category struct {
    oro.Model
    nestedset.NodeFields

    Name string
}

func (Category) Define(s *oro.SchemaBuilder) {
    s.Table("categories")
    s.Field("Name").String().Size(120)
}

默认列名:

字段 列名
ParentID parent_id
Lft _lft
Rgt _rgt
Depth depth

如果需要自定义字段名,在 DefineUse 两处使用同一份 nestedset.FieldNames(parent, left, right, depth)

创建节点

tree := nestedset.Use[Category](db)

root, err := tree.Create(ctx, &Category{Name: "Catalog"})
phones, err := tree.Create(ctx, &Category{
    Name: "Phones",
    NodeFields: nestedset.NodeFields{
        ParentID: oro.NullOf(root.ID),
    },
})

Create 会读取模型里的 ParentIDParentID 为空时创建根节点;ParentID 有值时创建为该父节点的最后一个子节点。

也可以使用明确位置的方法:

root, err := tree.CreateRoot(ctx, &Category{Name: "Catalog"})
phones, err := tree.CreateChild(ctx, root.ID, &Category{Name: "Phones"})
_, err = tree.CreateFirstChild(ctx, phones.ID, &Category{Name: "Android"})
_, err = tree.CreateAfter(ctx, phones.ID, &Category{Name: "Laptops"})
  • CreateRoot 新建根节点。
  • CreateChild 追加为最后一个子节点。
  • CreateFirstChild 插入为第一个子节点。
  • CreateBeforeCreateAfter 插入为兄弟节点。

结构性写入会自动运行在事务中。

普通 ORM 写入,例如 db.Use[Category]().Create(...),不会维护 nested set 的左右值。树形相关写入统一使用 tree 服务。

更新节点

phones.Name = "Smart Phones"
phones.ParentID = oro.NullOf(newParentID)

updated, err := tree.Update(ctx, phones)

Update 只写入模型上已设置(非零值)的字段,不再用零值覆盖未设置的其他列。它会把模型里的 ParentID 和数据库当前行对比,如果 ParentID 变化,会移动整棵子树,并重新计算 _lft_rgtDepth

如果只想按 id 更新部分列,而不必重新加载整个结构体,使用 UpdateValues

updated, err := tree.UpdateValues(ctx, node.ID, oro.Map{"Name": "New name"})
func (tree *Tree[T]) UpdateValues(ctx context.Context, nodeID any, values oro.Map, options ...oro.WriteOption) (*T, error)

由于 Update 会跳过零值,如果需要把某个字段显式设置为零值,请使用带 oro.MapUpdateValues

如果需要指定移动到兄弟节点前后或第一个子节点,使用下面的显式移动方法。

读取树

roots, err := tree.Roots(ctx)
all, err := tree.All(ctx)
nested, err := tree.Tree(ctx)
subtree, err := tree.Subtree(ctx, phones.ID)
flatSubtree, err := tree.FlatSubtree(ctx, phones.ID)

TreeSubtree 返回嵌套节点:

type Node[T any] struct {
    Model    *T
    Children []*Node[T]
}

读取关系

parent, err := tree.Parent(ctx, nodeID)
children, err := tree.Children(ctx, nodeID)
first, err := tree.FirstChild(ctx, nodeID)
last, err := tree.LastChild(ctx, nodeID)
siblings, err := tree.Siblings(ctx, nodeID)
prev, err := tree.PrevSibling(ctx, nodeID)
next, err := tree.NextSibling(ctx, nodeID)
ancestors, err := tree.Ancestors(ctx, nodeID)
ancestorsAndSelf, err := tree.AncestorsAndSelf(ctx, nodeID)
descendants, err := tree.Descendants(ctx, nodeID)
descendantsAndSelf, err := tree.DescendantsAndSelf(ctx, nodeID)

单节点读取未命中返回 nil, nil。多节点读取未命中返回空切片。

相对层级

数据库里的 Depth 是整棵树内的绝对层级。分销树、组织树等业务界面通常需要相对层级:当前节点是 0,直接子级是 1,孙级是 2

rows, err := tree.DescendantsWithDepth(ctx, nodeID)
rows, err := tree.DescendantsAndSelfWithDepth(ctx, nodeID)

返回结构:

type RelativeNode[T any] struct {
    Model *T
    Depth int // 相对层级
}

按相对层级限制后代:

items, err := tree.DescendantsWithinDepth(ctx, nodeID, 2) // 子级和孙级
items, err := tree.DescendantsAtDepth(ctx, nodeID, 1)     // 只查直接子级

DescendantsAndSelfWithinDepthDescendantsAndSelfAtDepth 会包含当前节点。这些方法仍然使用 SQL 的左右值和 depth 条件过滤,不会先加载整张表。

判断方法

ok, err := tree.IsRoot(ctx, nodeID)
ok, err := tree.IsLeaf(ctx, nodeID)
ok, err := tree.IsAncestorOf(ctx, ancestorID, nodeID)
ok, err := tree.IsDescendantOf(ctx, nodeID, ancestorID)

移动节点

err := tree.MoveToRoot(ctx, nodeID)
err := tree.MoveToChildOf(ctx, nodeID, parentID)
err := tree.MoveToFirstChildOf(ctx, nodeID, parentID)
err := tree.MoveBefore(ctx, nodeID, targetID)
err := tree.MoveAfter(ctx, nodeID, targetID)
err := tree.MoveUp(ctx, nodeID)
err := tree.MoveDown(ctx, nodeID)

节点不能移动到自身或自己的后代中。移动会保留整个子树。

删除子树

deleted, err := tree.Delete(ctx, nodeID)
deleted, err := tree.DeleteSubtree(ctx, nodeID)

DeleteDeleteSubtree 都会删除当前节点和所有后代,然后闭合树区间。

多棵树隔离

一张表里放多棵独立树时使用 Scope,例如租户树或应用树:

tree := nestedset.Use[Category](db, nestedset.Scope(oro.Map{
    "TenantID": uint64(1),
}))

scope 会应用到所有读取和结构性写入。新建节点时,scope 值也会写入模型。

统一 Apply 查询

复杂读取推荐直接使用核心查询链的 Apply

nodes, err := db.Use[Category]().
    Apply(nestedset.DepthGte(1), nestedset.DefaultOrder()).
    Get(ctx)

items, err := db.Use[Category]().
    Apply(nestedset.DescendantsOf(root), nestedset.DefaultOrder()).
    Where("Status", "active").
    Get(ctx)

DescendantsOf(root) 可以传当前模型;扩展会按模型 ID 重新读取最新左右值,避免使用过期对象上的 _lft/_rgt

支持 RootsAncestorsOfAncestorsAndSelfOfDescendantsOfDescendantsAndSelfOfDescendantsWithinDepthOfDescendantsAtDepthOfSiblingsOfDepthDepthGteDepthLteDefaultOrderReversed

tree.Query() 仍作为兼容薄封装保留,但新代码推荐统一使用 db.Use[Category]().Apply(...),这样不会让扩展包再维护一套 Where/Get/Limit API。

检查和重建

result, err := tree.Check(ctx)
if err != nil {
    return err
}
if !result.Valid {
    err = tree.Rebuild(ctx)
}

Rebuild 会根据 ParentID 重新计算 LftRgtDepth

也可以传入显式嵌套结构并持久化:

err := tree.RebuildTree(ctx, []*nestedset.Node[Category]{
    {
        Model: &Category{Name: "Root"},
        Children: []*nestedset.Node[Category]{
            {Model: &Category{Name: "Child"}},
        },
    },
})

payload 里的新模型会插入;带 ID 的已有模型会更新。

编辑此页