Directus 数据查询引擎深度解析:runAst() 如何将 REST/GraphQL 请求翻译成 SQL
本文以 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 请求发送给数据库。
这条链路在仓库中可以拆成三段:
- 请求参数 → AST:由 getAstFromQuery() 完成。它接收
collection与Query对象(含fields、deep、filter、sort、limit等),把fields解析成 AST 子节点,并通过parseFields()递归处理嵌套字段,同时结合权限信息写入各节点的cases/whenCase字段。值得注意的是,该方法还会处理一些 SQL 语义约束:使用聚合函数(aggregate)时清空普通字段选择,使用group时让分组字段覆盖fields(见 get-ast-from-query.ts 中对fields的三次修正)。 - AST → Knex 查询:由 runAst() 完成,即本文档的主题。
- 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(集合名)、children、query、cases |
描述一个集合的完整查询 |
FieldNode |
'field' |
name、fieldKey、alias、whenCase |
基础字段,是 AST 的叶子节点 |
M2ONode |
'm2o' |
relation、parentKey、relatedKey、children、query、cases |
多对一关系,递归执行后把关联对象注入父项 |
O2MNode |
'o2m' |
同 M2O | 一对多关系,递归执行后把关联记录数组注入父项 |
A2MNode |
实际判别值为 'a2o' |
names(多个集合)、children/query/relatedKey 按集合作字典 |
多对任意(Any-to-Many/Many-to-Any)关系 |
FunctionFieldNode |
'functionField' |
name、query、relatedCollection |
函数字段,如 count(relations.x) |
几点与文档对应并可由源码佐证的事实:
- FieldNode 是叶子节点。在 parse-current-level.ts 中,
field与functionField类型的子节点只负责把列名加入当前层的 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 形式存在,不单独成节点。
A2MNode(type: 'a2o')还有一个结构上的特殊之处:它的 children、query、relatedKey 都是按集合名字典,因此 runAst() 入口对 ast.type === 'a2o' 做了单独分支——对每个集合分别执行 run(),返回 { [collection]: Item | Item[] } 的字典结果。
三、runAst() 的执行流程:一次调用发生了什么
结合 run-ast.ts 的 run() 内部函数,一次完整的执行可拆为 8 步:
- 解析当前层(
parseCurrentLevel):从 AST 子节点中拆出fieldNodes(当前层要 SELECT 的列)、nestedCollectionNodes(关系节点)与主键字段。这里有两个关键细节:- M2O/A2O 节点会把关系外键列加进内部 SELECT 列表(嵌套合并需要它);
- 非聚合查询会始终附加主键字段,即使请求没有显式索要它——这是嵌套关系合并所必需的"临时字段"。
- 权限准备:若
accountability存在且非管理员,则通过fetchPolicies()/fetchPermissions()拉取当前角色的 read 权限与策略,供后续 SQL 层的 CASE WHEN 使用。 - 构建数据库查询(
getDBQuery):生成 Knex 查询构建器(详见第五节)。 - 执行查询:
const rawItems = await dbQuery;,得到数据库原始行。 - Payload 转换:用
PayloadService.processValues('read', rawItems, aliasMap, query.aggregate)处理token等特殊字段类型,并按 alias 映射还原字段名。 - 应用父级过滤(
applyParentFilters):对嵌套节点的_in过滤器按父项主键分组,避免"全量拉取后再内存过滤"。 - 递归拉取嵌套数据:
- O2M 节点按批拉取:循环以
RELATIONAL_BATCH_SIZE环境变量为每批limit、递增offset反复调用runAst(),直到返回的批次小于批大小。这是控制大集合一对多查询内存占用的核心机制; - M2O/A2O 节点:一次性以
limit: -1(不限)调用runAst()拉取全部关联记录; - 每一批结果由 mergeWithParentItems() 按外键/主键映射合并回父项,并在内存中应用嵌套层自身的
page/offset/limit/sort(注意:嵌套的排序与分页是在合并后于内存中完成的,见merge-with-parent-items.ts中对 o2m 的slice与sort处理)。 - 带
whenCase的 O2O/O2M 节点:数据库层只返回一个布尔标志(该用户对该项是否有权限访问此字段),runAst()在内存中提取并删除该标志字段,无权限的项直接置null。
- O2M 节点按批拉取:循环以
- 剥离临时字段:非嵌套调用且
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.aggregate 或 query.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.ts 中 wrapperQuery 的构造。
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'
)
要点有三:
- 顶层过滤用子查询防止重复行(
WHERE pages.id IN (SELECT ...)),与上一节"内层查询去重主键"的思路一致; - 嵌套 JOIN 使用随机别名(如
xviqp)防止命名冲突,别名由generateJoinAlias()生成并同时写入aliasMap——这就是文档所说的"注册进 aliasMap 以便复用":当同一集合因多个过滤/排序条件需要再次 JOIN 时,followRelation()会先查aliasMap[path],命中则跳过leftJoin,未命中才真正 JOIN; - 四种关系类型各有 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 数据访问层(如编写扩展、调试复杂嵌套查询性能、排查权限返回异常)的基础。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00