首页
/ Directus 数据查询引擎深度解析:runAst() 如何将 REST/GraphQL 请求翻译成 SQL

Directus 数据查询引擎深度解析:runAst() 如何将 REST/GraphQL 请求翻译成 SQL

2026-09-05 14:33:35作者:管翌锬

本文以 Directus 仓库内部文档 api/src/database/run-ast/README.md 为主体,结合 api/src/database/run-ast/api/src/types/ast.ts 的源码实现,系统讲解 Directus 查询引擎的核心机制:一条 REST、GraphQL 或 WebSocket 请求如何被转成 AST(抽象语法树),再由 runAst() 递归翻译成一条或多条 SQL 请求,以及 getDBQuery()applyQuery()addJoin() 三个关键函数在其中的分工。读完后你可以理解 Directus 嵌套查询、嵌套过滤/排序、权限字段级控制(CASE WHEN)与 O2M 批量拉取等能力的底层原理,并能定位相关源码进行测试或扩展。

一、整体链路:从请求到 SQL

内部文档开篇就给出了核心结论:

当请求通过 REST、GQL 或 WebSockets 进入系统时,Directus 会先把请求转换为一棵 AST,然后再把这棵 AST 翻译成一条或多条 SQL 请求发送给数据库。

这条链路在仓库中可以拆成三段:

  1. 请求参数 → AST:由 getAstFromQuery() 完成。它接收 collectionQuery 对象(含 fieldsdeepfiltersortlimit 等),把 fields 解析成 AST 子节点,并通过 parseFields() 递归处理嵌套字段,同时结合权限信息写入各节点的 cases/whenCase 字段。值得注意的是,该方法还会处理一些 SQL 语义约束:使用聚合函数(aggregate)时清空普通字段选择,使用 group 时让分组字段覆盖 fields(见 get-ast-from-query.ts 中对 fields 的三次修正)。
  2. AST → Knex 查询:由 runAst() 完成,即本文档的主题。
  3. Knex 查询 → SQL:由 Knex 查询构建器最终生成 SQL 并执行,结果经 PayloadService 做特殊字段(如 token、文件)转换后返回。

runAst() 的函数签名是理解其定位的关键:

export async function runAst(
  originalAST: AST | NestedCollectionNode,
  schema: SchemaOverview,
  accountability: Accountability | null,
  options?: RunASTOptions,
): Promise<null | Item | Item[]>

参数含义(见 types.ts):

参数 说明
originalAST 根 AST,或某个嵌套集合节点(递归调用时传入)
schema 当前数据库的 SchemaOverview,提供集合、字段、关系元数据
accountability 当前请求的权限上下文,null 表示系统级调用
options.query 覆盖当前层级的查询参数(嵌套批次查询时使用)
options.knex 可注入的 Knex 实例,不传则用全局数据库连接
options.nested 是否处于另一个 AST 的嵌套数据集中
options.stripNonRequested 是否自动剥离未请求但为嵌套需要的临时字段(如主键/外键)

二、AST 的节点模型:RootNode 与四类子节点

文档描述了 AST 的层级结构:根节点(RootNode)引用一组子节点,每个子节点可以是基础字段(FieldNode)关系字段(M2ONode、O2MNode、A2MNode)函数字段(FunctionFieldNode),关系字段还可以继续拥有子节点和嵌套查询。

仓库中的类型定义在 ast.ts,可作为节点模型的权威参考:

节点类型 判别字段 type 关键属性 作用
AST(RootNode) 'root' name(集合名)、childrenquerycases 描述一个集合的完整查询
FieldNode 'field' namefieldKeyaliaswhenCase 基础字段,是 AST 的叶子节点
M2ONode 'm2o' relationparentKeyrelatedKeychildrenquerycases 多对一关系,递归执行后把关联对象注入父项
O2MNode 'o2m' 同 M2O 一对多关系,递归执行后把关联记录数组注入父项
A2MNode 实际判别值为 'a2o' names(多个集合)、children/query/relatedKey 按集合作字典 多对任意(Any-to-Many/Many-to-Any)关系
FunctionFieldNode 'functionField' namequeryrelatedCollection 函数字段,如 count(relations.x)

几点与文档对应并可由源码佐证的事实:

  • FieldNode 是叶子节点。在 parse-current-level.ts 中,fieldfunctionField 类型的子节点只负责把列名加入当前层的 SELECT 列表(columnsToSelectInternal),不会再产生下级查询。
  • 关系节点递归调用 runAst()。在 run-ast.ts 中,run() 遍历 nestedNodes 时,对每个嵌套节点执行 runAst(node, schema, accountability, { knex, nested: true }),这正是文档所说"关系字段会递归调用 runAst(),结果数据随后被注入到上一层的数据中"。
  • FunctionFieldNode 的特殊性。文档指出"函数字段节点唯一会被插入 AST 的情形是 count(o2m_relation) 这种案例,其他函数保留为 FieldNode、函数名包含在字段名中"。这一点在源码中亦有印证:parseCurrentLevel 会对 functionField 节点做 extractFunctionName(child.name) === 'json' 的特判(解析 json('field', 'path') 函数),而 run-ast.ts 中构建 aliasMap 时同样只对 json 函数字段做映射——从源码结构看,普通 SQL 函数(如 year(date))在字段解析阶段就以内嵌函数名的 FieldNode 形式存在,不单独成节点。

A2MNodetype: 'a2o')还有一个结构上的特殊之处:它的 childrenqueryrelatedKey 都是按集合名字典,因此 runAst() 入口对 ast.type === 'a2o' 做了单独分支——对每个集合分别执行 run(),返回 { [collection]: Item | Item[] } 的字典结果。

三、runAst() 的执行流程:一次调用发生了什么

结合 run-ast.tsrun() 内部函数,一次完整的执行可拆为 8 步:

  1. 解析当前层parseCurrentLevel):从 AST 子节点中拆出 fieldNodes(当前层要 SELECT 的列)、nestedCollectionNodes(关系节点)与主键字段。这里有两个关键细节:
    • M2O/A2O 节点会把关系外键列加进内部 SELECT 列表(嵌套合并需要它);
    • 非聚合查询会始终附加主键字段,即使请求没有显式索要它——这是嵌套关系合并所必需的"临时字段"。
  2. 权限准备:若 accountability 存在且非管理员,则通过 fetchPolicies() / fetchPermissions() 拉取当前角色的 read 权限与策略,供后续 SQL 层的 CASE WHEN 使用。
  3. 构建数据库查询getDBQuery):生成 Knex 查询构建器(详见第五节)。
  4. 执行查询const rawItems = await dbQuery;,得到数据库原始行。
  5. Payload 转换:用 PayloadService.processValues('read', rawItems, aliasMap, query.aggregate) 处理 token 等特殊字段类型,并按 alias 映射还原字段名。
  6. 应用父级过滤applyParentFilters):对嵌套节点的 _in 过滤器按父项主键分组,避免"全量拉取后再内存过滤"。
  7. 递归拉取嵌套数据
    • O2M 节点按批拉取:循环以 RELATIONAL_BATCH_SIZE 环境变量为每批 limit、递增 offset 反复调用 runAst(),直到返回的批次小于批大小。这是控制大集合一对多查询内存占用的核心机制;
    • M2O/A2O 节点:一次性以 limit: -1(不限)调用 runAst() 拉取全部关联记录;
    • 每一批结果由 mergeWithParentItems() 按外键/主键映射合并回父项,并在内存中应用嵌套层自身的 page/offset/limit/sort(注意:嵌套的排序与分页是在合并后于内存中完成的,见 merge-with-parent-items.ts 中对 o2m 的 slicesort 处理)。
    • whenCase 的 O2O/O2M 节点:数据库层只返回一个布尔标志(该用户对该项是否有权限访问此字段),runAst() 在内存中提取并删除该标志字段,无权限的项直接置 null
  8. 剥离临时字段:非嵌套调用且 stripNonRequested !== false 时,removeTemporaryFields() 把第 1 步注入的主键/外键等未请求字段从最终结果中移除——这也是 RunASTOptions.stripNonRequested 选项存在的意义。

四、getDBQuery():SQL 查询的组装与"内层查询"之谜

README 在 getDBQuery() 一节留了一个 TODO("详细描述 getDBQuery 到底做了什么、为何需要内层查询、为何在不同位置多次调用 applyQuery()"),这恰好可以结合 get-db-query.ts 源码补全。

getDBQuery({ table, fieldNodes, o2mNodes, query, cases, permissions }, { knex, schema }) 返回一个 Knex.QueryBuilder。其内部逻辑可归纳为四条分支:

1. 聚合/分组查询走"扁平查询"

query.aggregatequery.group 存在,直接用 knex.from(table) 构建查询,把 group 字段映射到对应的 fieldNode 位置(偏移量要加上聚合列数量),调用一次 applyQuery()不做内层查询。这是因为聚合结果本身不存在行重复问题。

2. 常规查询:是否需要内层查询(inner query)

先执行 applySort() 得到 sortRecords,再调用 applyQuery()(传 isInnerQuery: true)应用过滤。由两者共同得出:

const needsInnerQuery = hasMultiRelationalSort || hasMultiRelationalFilter;
  • 不需要内层查询:直接 select 各列;对有 whenCase 的 O2M 字段,额外 SELECT 一个 applyCaseWhen() 生成的标志列,用于在数据库层做字段级权限判断
  • 需要内层查询:内层只 SELECT 主键(无 CASE WHEN 时加 DISTINCT),把排序列也 SELECT 出来作为别名,然后按 rowNumber() 窗口函数处理"每个父分区的第一行"(多关系排序场景,见下)。

为什么要内层查询? 当对"一对多关系字段"做过滤或排序(如 ?filter[articles.author.name][_eq]=Rijk?sort=articles.date)时,JOIN 会把父表行放大成多行。若直接排序分页,分页边界会切到同一个父项的多行中间,产生重复或漏项。解决方案是:内层查询完成 JOIN、过滤、排序,只输出去重后的主键(SELECT DISTINCT pages.id ...),外层查询再用 innerJoin(... inner ...) 按主键回连主表取完整列——见 get-db-query.tswrapperQuery 的构造。

3. 字段级权限的 CASE WHEN / GROUP BY 技巧

这是源码中大段注释解释的核心(get-db-query.ts L247-L277 的注释):当存在 whenCase(字段级权限)时,CASE WHEN 表达式必须在内层查询中求值(因为所需 JOIN 表只在那里可见),但 SELECT DISTINCT 对 CASE WHEN 表达式并非所有数据库都支持。于是改用 GROUP BY 主键 + COUNT(CASE WHEN 条件 THEN 1 END) AS 标志列 的写法:只要分组内任一行允许访问该列,标志 > 0。外层查询则退化为简单的 CASE WHEN inner.标志 > 0 THEN 实际列 END,无需重复求值。

4. 多关系排序的行号技巧

对多关系排序(hasMultiRelationalSort),内层用 ROW_NUMBER() OVER (PARTITION BY 主键 ORDER BY 排序列) 窗口函数生成 directus_row_number,外层 WHERE inner.directus_row_number = 1 保证每个父项只取"排名第一的关联行",然后再应用 limit——避免 limit 截断发生在去重之前。

五、applyQuery():把 Query 参数翻译成 SQL 子句

文档对 applyQuery() 的概括是:它接收查询参数并修改 SQL 请求,使过滤、排序等参数生效。对照 apply-query/index.ts 的实现,其执行顺序是固定的:

步骤 源码调用 对应 Query 参数 说明
1 applyLimit() limit 未指定时 getDBQuery 会用 QUERY_LIMIT_DEFAULT 环境变量兜底
2 applyOffset() offset / page page 换算为 limit * (page - 1) 的 offset
3 applySort() sort 非内层查询时才在此应用;内层查询的排序由 getDBQuery 前置处理
4 joinFilterWithCases() + applyFilter() filter 用户过滤条件与权限 cases 合并后应用
5 groupBy() group 支持按列位置(数据库能力差异由 helpers 处理)
6 applySearch() search 全文搜索
7 applyAggregate() aggregate 聚合统计

权限过滤的注入方式值得单独说明。applyQuery 的注释写道:"cases 是当前数据集需要的权限条件,我们会把它们动态加入用户提供的过滤器中以强制权限规则。只要任一 case 匹配,你就应该能读到该条数据;同一个 case 还会在列选择的 CASE WHEN 中被复用,用于动态返回或置空你实际有权限读取的字段值。" 实现见 join-filter-with-cases.ts

export function joinFilterWithCases(filter: Filter | null | undefined, cases: Filter[]) {
  if (cases.length > 0 && !filter) {
    return { _or: cases };
  } else if (filter && cases.length === 0) {
    return filter ?? null;
  } else if (filter && cases.length > 0) {
    return { _and: [filter, { _or: cases }] };
  }
  return null;
}

即:用户过滤条件与"角色权限 OR 策略"合并为 _and,角色权限之间为 _or

六、addJoin() 与 aliasMap:嵌套过滤/排序如何避免重复 JOIN

README 的最后一节描述了 addJoin() 的特殊行为:"对嵌套字段做过滤、排序等特殊行为,为了让其成为可能,addJoin() 会 JOIN 所需表,并把自己注册进 aliasMap,这样下次需要 JOIN 同一张表时,会直接复用已有的 JOIN 而不是再次 JOIN。"

add-join.ts 的实现精确对应了这一描述,且其头部注释给出了一个完整示例——"查询有 Rijk 所写文章的 pages":

{
  "articles": {
    "author": {
      "name": { "_eq": "Rijk" }
    }
  }
}

生成的 SQL 形如:

SELECT *
FROM pages
WHERE
  pages.id in (
    SELECT articles.page_id AS page_id
    FROM articles
    LEFT JOIN authors AS xviqp ON articles.author = xviqp.id
    WHERE xviqp.name = 'Rijk'
  )

要点有三:

  1. 顶层过滤用子查询防止重复行WHERE pages.id IN (SELECT ...)),与上一节"内层查询去重主键"的思路一致;
  2. 嵌套 JOIN 使用随机别名(如 xviqp)防止命名冲突,别名由 generateJoinAlias() 生成并同时写入 aliasMap——这就是文档所说的"注册进 aliasMap 以便复用":当同一集合因多个过滤/排序条件需要再次 JOIN 时,followRelation() 会先查 aliasMap[path],命中则跳过 leftJoin,未命中才真正 JOIN;
  3. 四种关系类型各有 JOIN 方式m2o 走外键-主键 LEFT JOIN;a2o(多对任意)要求路径中提供集合作用域(field:scope),并按 one_collection_field 值匹配 + 主键类型转换(castA2oPrimaryKey())JOIN;o2a/o2m 标记 hasMultiRelational,触发第五节所述的内层查询。

七、关键配置与源码入口速查

运行该查询引擎时,两个环境变量会直接影响行为:

  • RELATIONAL_BATCH_SIZE:O2M 嵌套查询的每批拉取行数,控制递归 runAst() 的批次循环(run-ast.ts L147-L167);
  • QUERY_LIMIT_DEFAULT:未显式传 limit 时的默认分页上限(get-db-query.ts L48)。

建议按以下路径继续深入阅读:

关注点 入口文件
内部文档(本文主体) api/src/database/run-ast/README.md
AST 节点类型定义 api/src/types/ast.ts
请求 → AST api/src/database/get-ast-from-query/get-ast-from-query.ts
AST → 执行 api/src/database/run-ast/run-ast.ts
SQL 组装与内层查询 api/src/database/run-ast/lib/get-db-query.ts
查询参数应用 api/src/database/run-ast/lib/apply-query/index.ts
JOIN 与别名复用 api/src/database/run-ast/lib/apply-query/add-join.ts
嵌套结果内存合并 api/src/database/run-ast/utils/merge-with-parent-items.ts
当前层列解析 api/src/database/run-ast/lib/parse-current-level.ts
单元测试(过滤/排序/分页/JOIN) api/src/database/run-ast/lib/apply-query/ 下各 *.test.ts

八、小结

Directus 的 runAst() 本质上是一个面向关系模型的查询编译器

  • AST 层用四种节点(Field、M2O、O2M、A2M、FunctionField)精确描述"要取什么数据",权限条件以 cases/whenCase 的形式与数据结构绑定;
  • 编译层getDBQuery + applyQuery + addJoin)负责把查询参数翻译成 SQL,并用内层查询去重、CASE WHEN/GROUP BY 标志列、aliasMap JOIN 复用三种技巧,分别解决"多关系过滤/排序的重复行"、"字段级权限"和"嵌套路径的 JOIN 冲突"三个 SQL 层面的固有难题;
  • 执行层通过递归 runAst() 逐层拉取嵌套数据,O2M 用批处理控制内存,最后在内存中按外键合并、剥离临时字段,输出与请求结构一一对应的嵌套 JSON。

理解这套机制,是定制 Directus 数据访问层(如编写扩展、调试复杂嵌套查询性能、排查权限返回异常)的基础。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384