跳到主要内容

SQL 查询引擎与校验沙箱

30 秒导读: Laminar 的 README 承诺「SQL access to all data」——用户能用真正的 SQL 查自己项目里所有的 trace / span。本章讲清楚这句承诺背后那层安全垫子:一个跑在 app-server 进程内、基于 sqlparser 的「解析 → 校验 → 改写」层,它把用户的 SQL 只放行 SELECT,把表/列限制在白名单里,并偷偷给每张表注入 project_id 过滤,让「把整库开放给你查」和「你查不到别人的数据、也注入不进危险函数」这两件看似矛盾的事同时成立。

本章聚焦 app-server/src/query_engine/ 这个模块和它的 HTTP 入口。它不讲底层 ClickHouse 表结构(那是 04-storage-realtime.md 的内容),只讲「用户给的一段字符串,怎么被安全地变成一条能打给 ClickHouse 的 SQL」。


1. 这是什么(零基础也能懂)

一句话定义

查询引擎 = 一个把用户 SQL 当成不可信输入、逐字审查并悄悄改写后才放行的关卡。

它对外暴露三个能力:校验并改写一条 SQL、把 SQL 转成结构化 JSON、把结构化 JSON 转回 SQL。

它解决谁的什么问题

假设你在用 Laminar 观测你的 AI agent。你想问一个产品经理式的问题:「过去 7 天,每个模型的 p90 延迟是多少?」——Laminar 允许你直接写 SQL:

SELECT model, quantile(0.9)(duration) AS p90
FROM spans
WHERE start_time >= now() - INTERVAL 7 DAY
GROUP BY model

问题来了:你的 spans 表和成千上万个其他项目的 span 物理上存在同一批 ClickHouse 表里。如果这条 SQL 原封不动打给 ClickHouse,你就能查到所有人的数据——甚至可以写 SELECT * FROM url('http://attacker.com') 让 ClickHouse 去访问外部网络。

所以中间必须有个关卡,它要同时满足三条:

关卡要保证白话
只读你只能 SELECT,不能 INSERT/DROP/ALTER
只看自己的数据你的每次查询自动只覆盖你自己的 project_id
不越权、不注入不能调用能读文件/联网/读别项目字典的 ClickHouse 函数

它能做什么

  • 校验并加固一条 SQL(validate_query):解析→审查→改写,产出一条「已被 project 过滤、已确认安全」的 SQL,或一条拒绝理由。
  • SQL ⇄ JSON 双向转换(sql_to_json / json_to_sql):让「图形化图表构建器」和「手写 SQL 编辑器」这两个前端界面能互相切换——用户在 UI 上拖出来的查询能转成 SQL,手写的 SQL 也能尽量还原成 UI 结构。

用起来什么样

前端(或 CLI、MCP 工具)把一条 SQL POST 给 app-server,引擎回一条改写后的 SQL。你写:

SELECT span_id, name FROM spans

引擎放行后实际打给 ClickHouse 的是(注意多出来的 _v0(...)project_id):

SELECT span_id, name
FROM spans_v0(project_id = '<你的项目 UUID>') AS spans

一句话直觉

把它想成餐厅的传菜口:顾客(用户 SQL)不能直接冲进后厨(ClickHouse)。所有单子都要过传菜口——传菜口只收「点菜」不收「拆灶台」(SELECT-only),只允许点菜单上的菜(表/列白名单),而且每张单子都会被盖一个「几号桌」的章(project_id),后厨永远只按盖了章的桌号出餐。


2. 顶层全景(它大概怎么转)

部件职责

部件干什么在哪个文件
QueryEngine门面:持有一个校验器,暴露三个 async 方法query_engine/mod.rs:22 QueryEngine
QueryValidator安全核心:解析、校验、改写 SQLquery_engine/validator.rs:443 QueryValidator
TableRegistry表/列白名单query_engine/validator.rs:211 TableRegistry
convert_json_to_sql把结构化查询 JSON 生成 SQLquery_engine/json_to_sql.rs:58
convert_sql_to_json把 SQL 反解析成结构化 JSONquery_engine/sql_to_json.rs:21
QueryStructureJSON 与 SQL 之间的中间数据模型query_engine/types.rs:60 QueryStructure
HTTP 路由(前端)/api/v1/projects/{id}/sql/* 四个入口routes/sql.rs
HTTP 路由(公开/CLI)/v1/sql/query 等,带 API key 鉴权api/v1/sql.rs

一条 SQL 的执行主线

先看「执行一条查询」这条最重要的路径怎么走。关键点:校验一定在执行之前,而且执行用的是校验器吐出来的那条改写后的 SQL,不是用户原文。

用户 SQL 字符串


[HTTP 路由] routes/sql.rs::execute_sql_query
│ (或 api/v1/sql.rs,带 project API key)

[sql::execute_sql_query] sql/mod.rs:104

├─①─► validate_query ──► QueryEngine.validate_query ──► QueryValidator
│ 成功: 得到「改写后的 SQL」 失败: 直接返回拒绝理由

└─②─► route_and_run_query(只拿改写后的 SQL 去打 ClickHouse)


ClickHouse 只读客户端

引擎自己不执行 SQL,只负责第 ① 步的校验改写;真正打 ClickHouse 在 sql/mod.rs::route_and_run_query。本章讲的就是第 ① 步这个盒子里发生了什么。

validate_and_secure_query 内部的流水线

校验器内部是一条固定顺序的多趟(pass)流水线,顺序本身就是安全设计的一部分:

parse_clickhouse_sql ── 解析成 AST(含一个 IN 占位符补丁)


只能是 1 条语句 & 必须是 Query(SELECT) ── 否则拒绝


validate_security ── 扫描黑名单函数(file/url/remote/dictGet…)


validate_cte_names ── 拒绝与白名单表同名的 CTE(防影子表)


validate_tables_columns ── 表在白名单?列在白名单?project_id 列?


ViewRewriter (VisitMut) ── 把每个 spans → spans_v0(project_id='…')


strip_settings ── 删掉用户塞的 SETTINGS 子句


validate_post_rewrite ── 兜底复查:还有裸表没被改写吗?混进黑名单函数了吗?


statement.to_string() ── 输出改写后的 SQL

真源码:validator.rs:462 validate_and_secure_query 就是按这个顺序一趟趟调下来的。先校验、后改写、再兜底复查——这种「改写完还要再扫一遍」的 fail-closed(失败即拒绝)姿态,是这层沙箱最值得学的地方。


3. 核心原理(逐个机制,由浅入深)

3.1 门面:三个方法,一个校验器

它要解决的小问题: 给上层(HTTP 路由、MCP 工具)一个简单、稳定的调用面,别让它们直接碰解析器细节。

思路: QueryEngine 是个几乎无状态的 plain struct,里面只揣着一个 QueryValidator。三个 async 方法各转发到对应的自由函数。

真实实现在 query_engine/mod.rs:32——validate_query 把 UUID 转成字符串,调校验器,再把结果包成一个枚举:

// 示意,非源码;重点看它如何把「Ok/Err」翻译成对上层友好的 Success/Error 枚举
pub async fn validate_query(&self, query: String, project_id: Uuid)
-> Result<QueryEngineValidationResult>
{
match self.validator.validate_and_secure_query(&query, &project_id.to_string()) {
Ok(sql) => Ok(QueryEngineValidationResult::Success { validated_query: sql }),
Err(err) => Ok(QueryEngineValidationResult::Error { error: err }),
}
}

关键细节: 注意「校验失败」返回的是 Ok(Error{...}) 而不是 Err——即拒绝是一种正常业务结果,不是系统异常。真正的 Err 留给「解析器内部炸了」这种意外(见 mod.rs:16 QueryEngineValidationResult)。这个模块是内联在 app-server 进程里的:文件头注释直接写了它「替代了原来走 gRPC 的 Python query-engine 服务」(mod.rs:1),把一次网络跳数省掉了。

3.2 表/列白名单:能查什么,是写死的

它要解决的小问题: 用户不能查 system.users,也不能查 spans 里那个不该暴露的 project_id 列。

思路: 维护一张硬编码的注册表:表名 → 允许的列集合。不在表里的表名一律拒,列同理。

真实实现是 TableRegistry::new(validator.rs:228),它把 spanstracesevaluation_datapointssignal_eventsclusterslabeling_queue_items 等十几张表连同各自的列清单塞进一个 HashMap。例如 spans 的列白名单(validator.rs:231)列出了 span_idnamedurationinputoutputtotal_cost 等——但没有 project_id,这是故意的。

表名与列名匹配都走小写归一化(validator.rs:433 is_table_allowedvalidator.rs:202 is_column_allowed),所以 SELECT SPAN_ID FROM SPANS 也认。

关键细节 · 白名单要四处同步: 这张后端注册表只是「执行闸门」。要让一张表/列真正对用户可见,得同时更新四个地方——注册表、前端 tableSchemas(自动补全 + AI 生成)、MCP 工具的 docstring、CLI 的 schema.ts。漏一个,列就查得到但在 UI/AI/CLI 里隐身。这条约束在仓库的开发笔记里被反复强调,是这套设计真实的维护成本。

3.3 project 级视图改写:整库开放的秘密

它要解决的小问题: 用户写 FROM spans,但物理表 spans 装着所有项目的数据。怎么让他只看到自己的?

思路: 每张物理表都有一个对应的参数化视图函数 <表名>_v0(project_id = '…')——视图体里内建了 WHERE project_id = {project_id:UUID}(见 04 章)。校验器的活儿就是:把用户写的每个裸表引用 spans,原地改写spans_v0(project_id = '<真实 UUID>'),并保留(或补上)别名。

改写靠一个 VisitorMut 遍历 AST,在每个 TableFactor::Table 节点上动手(validator.rs:833 ViewRewriter::pre_visit_table_factor)。核心几行(validator.rs:871):

// 示意,非源码;展示「裸表 → _v0 视图函数 + 注入 project_id + 保别名」
let view_name = format!("{table_name}_{VIEW_VERSION}"); // spans → spans_v0
*name = ObjectName(vec![ident(view_name)]);
*args = Some(TableFunctionArgs { // 注入 project_id 实参
args: vec![named_arg("project_id", string_expr(self.project_id))],
settings: None,
});
*alias = Some(TableAlias { name: 原别名或表名, explicit: true, .. });

改写前后对照:

用户写的改写后打给 CH 的
FROM spansFROM spans_v0(project_id = '<uuid>') AS spans
FROM traces tFROM traces_v0(project_id = '<uuid>') AS t
JOIN signal_events seJOIN signal_events_v0(project_id = '<uuid>') AS se

保留别名很关键:用户后面写的 t.duration 要还能解析到,所以别名 t 必须原样带过去(validator.rs:872)。测试 test_join_with_allowed_tables(validator/tests.rs:287)正是验证 JOIN 里两张表都被独立改写、别名都还在。

诚实提示(inferred): 仓库里的开发笔记提到 traces 会被改写成带 start_time/end_time 的视图函数,但在本 commit 的源码里,ViewRewriter所有表都只注入 project_id 这一个实参(validator.rs:878),测试 test_validate_basic_traces_select(validator/tests.rs:93)断言的也只是 traces_v0(project_id = '…')。所以「traces 额外注入时间边界」在当前代码里并不成立——以源码为准。

3.4 SELECT-only 与函数黑名单:堵死写操作和越权函数

它要解决的小问题: 挡住 DROP TABLE,也挡住 SELECT * FROM url('http://attacker.com') 这类能联网/读文件的 ClickHouse 表函数。

思路一(只读): 解析后先看语句类型,不是 Statement::Query(即不是 SELECT/WITH)一律拒(validator.rs:558)。像 INSERT/UPDATE/DROP 会被 sqlparser 解析成非 Query 语句,直接撞上这道闸(测试 test_reject_write_operations,validator/tests.rs:119)。

思路二(函数黑名单): 维护一份危险函数名集合 blocked_functions(validator.rs:28),覆盖几类:

类别例子
文件系统file
网络 / 远程表urlremoteremotesecure
云存储s3gcshdfsazureblobstorage
外部数据库表函数mysqlpostgresqlmongodbjdbc
字典 / join 表(可绕过表校验)dictGetjoinGet 家族
信息泄露currentDatabasecurrentUserhostNamegetSetting
DoSsleepsleepEachRow

光有精确名字还不够——dictGetStringdictGetUInt64joinGetOrNull 这类带类型后缀的变体会绕过精确匹配。所以还有一份前缀表 BLOCKED_FUNCTION_PREFIXES(validator.rs:94),is_blocked_function(validator.rs:100)先查精确集合、再按前缀兜底:

// 示意,非源码;精确名 OR 危险家族前缀,任一命中即拦
fn is_blocked_function(name: &str) -> bool {
blocked_functions().contains(name)
|| ["dictget", "dicthas", "dictis", "joinget"]
.iter().any(|p| name.starts_with(p))
}

dictGet 尤其危险:字典是按 (project_id, ...) 建键的,放行它等于给了用户一把绕过 project 过滤读别人数据的钥匙(测试 test_reject_typed_dict_and_join_get_functions,validator/tests.rs:678)。

黑名单的扫描器 BlockedScanner(validator.rs:681)同时检查两种位置:函数调用表达式(SELECT url(...))和关系名(FROM remote(...)——表函数在 AST 里是关系不是表达式),两处都查,一处漏网都不行。

3.5 结构化 JSON ⇄ SQL:图表构建器和 SQL 编辑器的桥

前端有两种查询界面:拖拽式的图表构建器、以及手写的 SQL 编辑器。两者要能互切,靠的就是 QueryStructure 这个中间模型和两个转换方向。

QueryStructure(types.rs:60)是这样一个声明式结构:

字段含义UI 上对应
table查哪张表数据源下拉
metrics聚合指标(fn+column+可选 args/alias)「Y 轴 / 度量」
dimensions分组维度(列名数组)「分组依据」
filters过滤条件(field+op+值)筛选器行
time_range时间桶 + 起止 + 步长 + 是否补空档时间选择器
order_by / limit排序 / 行数上限排序、Top-N

这个 serde 形状必须和前端 frontend/lib/actions/sql/types.ts 里的 zod schema 字字对齐(camelCase、stringValue/numberValue 扁平化到 filter 上)——types.rs:1 的文件头把这条契约写死了,是「绑定契约」。

方向一:JSON → SQL(json_to_sql.rs:58)

SELECT / FROM / WHERE / GROUP BY / ORDER BY / LIMIT 顺序拼字符串。合法的聚合函数被白名单卡住:count/sum/avg/min/max/quantile(json_to_sql.rs:19 ALLOWED_METRIC_FNS),其余报「Unsupported metric function」。

演示一下从结构到 SQL 的映射:

{ table: "spans",
metrics: [{ fn: "quantile", column: "duration", args: [0.9], alias: "p90" }],
dimensions: ["model"],
timeRange: { column: "start_time", from: "...", to: "...",
intervalValue: "5", intervalUnit: "MINUTE" } }
│ convert_json_to_sql

SELECT
toStartOfInterval(start_time, toInterval(5, MINUTE)) AS time,
model,
quantile(0.9)(duration) AS `p90`
FROM spans
WHERE start_time >= ... AND start_time <= ...
GROUP BY time, model
ORDER BY time

方向二:SQL → JSON(sql_to_json.rs:21)

反过来,解析一条 SQL,尽力还原成 QueryStructure,好让图表构建器能显示。它把投影项分成三类(sql_to_json.rs:125 parse_select_expressions):toStartOfInterval(...) → 识别为时间桶;出现在 GROUP BY 里的列 → 维度;其余带聚合函数的 → 指标。认不出的复杂表达式(如 countIf(...) / count(*))兜底成 fn: "raw" 指标(sql_to_json.rs:172 extract_metric)。

关键区别: sql_to_json 不是安全边界——它解析的 SQL 要么来自 convert_json_to_sql 生成、要么来自 SQL 编辑器(后者会另走 validate_query 校验)。文件头(sql_to_json.rs:1)明确说它只是「尽力而为的结构还原」。真正的安全边界是 validator.rs 和下面 3.6 讲的 raw 表达式路径。

3.6 raw 表达式:JSON 路径里的第二道安全边界

它要解决的小问题: 图表构建器允许用户填一段自定义 SQL 片段当指标(fn: "raw")。这段片段是用户可控的,会被拼进最终 SQL——又一个注入口。

思路: 别信任这段片段的原文。解析它 → 审查 → 从解析后的 AST 重新生成,让最终拼进去的 SQL 永远等于「被审查过的那棵树」,而不是原始字符串。

validate_raw_expression(json_to_sql.rs:272)的审查清单:

  1. 空表达式 → 拒。
  2. 含 SQL 注释 → 拒(用 tokenizer 判,避免把字符串字面量里的 -- 误判;tokenizer 报错则 fail-closed 当成有注释,json_to_sql.rs:236 contains_sql_comment)。
  3. 把片段包成 SELECT <expr> FROM t 再解析;必须正好是单个表达式
  4. 含子查询 → 拒(json_to_sql.rs:308 expr_contains_subquery)。
  5. 含黑名单函数 → 拒——复用 validator.rsfind_blocked_function_in_expr(json_to_sql.rs:312),跟主校验器同一套判定。
  6. 通过后,返回 inner_expr.to_string()——从 AST 重新序列化,而非用户原文。

这就是「解析→校验→重生成」模式:哪怕审查逻辑有边角遗漏,最终 SQL 也只可能是 sqlparser 能理解并重新打印的那棵树,注释、多余分号、藏在字符串外的花招都在重新序列化时被抹平。


4. 深入实现(值得细读的两处)

4.1 IN {placeholder} 的解析补丁

前端发来的参数化查询长这样:WHERE span_id IN {spanIds:Array(UUID)}。这里的 {spanIds:Array(UUID)} 是 ClickHouse 的查询参数占位符。但上游 sqlparser 有个 bug:IN 后面硬要一个 (,直接跟花括号占位符会解析失败(等价的 IN ({spanIds:Array(UUID)}) 却能解析)。

Laminar 的解法很克制:不在字符串上做正则替换(那会误伤字符串里的花括号),而是在 token 流层面动手。parse_clickhouse_sql(validator.rs:125)先 tokenize,再调 wrap_in_placeholder_lists(validator.rs:147):扫描 token,遇到「紧跟在 IN 关键字后的 {...} 花括号组」,就在它两侧插入 () 两个 token,然后直接用这个 token 流解析。

因为字符串字面量在 token 流里是一个不透明的 Token::SingleQuotedString,注释是 Token::Whitespace,所以字符串或注释里的 { 永远不会被匹配到。测试 test_in_placeholder_inside_string_literal_not_rewritten(validator/tests.rs:777)专门验证了这一点:name IN ('a IN {not a placeholder}', 'b') 里的花括号纹丝不动。

sql_to_json 也复用这同一个 shim(sql_to_json.rs:25),两个方向共用一套解析入口。

4.2 兜底复查:fail-closed 的防线

改写之后,校验器不直接信任自己改对了,而是再扫一遍(validator.rs:533 validate_post_rewrite):

  • BlockedScanner 重扫——确认改写过程没有意外引入黑名单函数。
  • PostRewriteChecker(validator.rs:785)遍历所有 TableFactor::Table:任何一个白名单表如果还以「裸关系」(没有 table-function 实参)存在,就说明它逃过了改写——直接报「Internal validation error: table '…' was not project-scoped」并拒绝。

换句话说:引擎宁可错杀(把一条本该没问题、但改写逻辑没覆盖到的查询拒掉),也不肯放走一条可能没被 project 过滤的 SQL。这是整层沙箱最重要的姿态——任何不确定都向「拒绝」一侧倒

还有两个配套细节:

  • CTE 影子表防护(validator.rs:518 validate_cte_names):CTE 名字收集是全局的(非词法作用域),所以一个内层定义的、名叫 spans 的 CTE 可能让外层真正的 FROM spans(物理表)被误当成 CTE 引用而跳过改写——这是跨租户泄露。解法简单粗暴:禁止任何 CTE 与白名单表同名(测试 test_nested_cte_shadowing_protected_table_rejected,validator/tests.rs:626)。
  • 剥离用户 SETTINGS(validator.rs:908 strip_settings):用户在 SQL 里塞的 SETTINGS 子句会被整个删掉,防止他覆盖服务器侧设的 max_execution_time / max_memory_usage 等保护性上限。

4.3 ARRAY JOIN 的假表陷阱

ClickHouse 的 ARRAY JOIN clusters AS cluster_id 里,clusters 是左表的一个数组列,不是 clusters 那张表——但 sqlparser 仍把它建模成 TableFactor::Table。如果不管,改写器会把它错改成 clusters_v0(...)

Laminar 靠**源码 span(位置)**来区分同名的两者:先用 collect_array_join_spans(validator.rs:650)收集所有 ARRAY JOIN 右侧关系的源码位置,后续每一趟(表校验、改写、兜底)都按 span 跳过这些位置(如 validator.rs:842)。这样即使查询里同时有真的 FROM clusters 和数组列 ARRAY JOIN clusters,也只有前者被改写(测试 test_array_join_does_not_shadow_real_clusters_table,validator/tests.rs:850,断言 clusters_v0 恰好出现一次)。


5. HTTP 路由入口:两条路,一个引擎

同一个 Arc<QueryEngine> 被两组路由共享,区别只在鉴权方式是否套用资源上限

前端可信路由(routes/sql.rs)

挂在 /api/v1/projects/{projectId}/sql/* 下,四个入口:

入口函数干什么
sql/queryexecute_sql_query(routes/sql.rs:71)校验 + 执行,返回结果行
sql/validatevalidate_sql_query(routes/sql.rs:113)只校验,返回改写后 SQL 或错误
sql/to-jsonsql_to_json(routes/sql.rs:147)SQL → QueryStructure
sql/from-jsonjson_to_sql(routes/sql.rs:174)QueryStructure → SQL

它执行查询时传的是 SqlQuerySource::Internal(routes/sql.rs:96)——可信来源,不套用 max_memory_usage 上限。

公开 / CLI 路由(api/v1/sql.rs)

/v1/sql/query(SDK)和 /v1/cli/sql/query(CLI)走这里,用 project API key 鉴权。两者共用 handle_sql_query(api/v1/sql.rs:66),里面还内联做每项目限流(ratelimit:<project_id>,限流器出错时 fail-open 放行)。它执行时传 SqlQuerySource::Public(api/v1/sql.rs:98)——要套用 max_memory_usage 上限,防止面向公网的查询把 ClickHouse 打到 OOM。

SqlQuerySource 这个枚举(sql/mod.rs:34)就是「可信 vs 公开」这条策略分界:

Internal(前端 / 内部调用)── 不限内存,信任来源
Public (公网 /v1 + CLI + MCP)── 套 max_memory_usage 上限

无论哪条路,执行前都强制过 validate_query(sql/mod.rs:104),拿改写后的 SQL 才去 route_and_run_query——没有任何入口能绕过校验直接执行用户原文


6. 巧妙之处(可借鉴的技术)

  • 解析→校验→重生成,而不是字符串黑名单。 无论主 SQL 还是 raw 片段,最终打给 CH 的都是「从 AST 重新序列化」的结果(validator.rs:514json_to_sql.rs:320)。这让审查建立在结构上,天然免疫注释注入、编码花招。
  • 多趟 + 兜底复查的 fail-closed。 改写完再扫一遍,任何未被 project 化的裸表都当成 bug 拒绝(validator.rs:533)。宁可错杀不可放过。
  • 精确名 + 家族前缀双层黑名单。 dictGetUInt64 这类后缀变体靠前缀兜住(validator.rs:94),堵死「换个类型后缀就绕过」。
  • token 流层面打解析器补丁。 IN {placeholder} 的 shim 只碰 token、不碰字符串字面量(validator.rs:147),既绕过上游 bug 又不误伤。
  • 靠源码 span 区分同名的表与数组列。 ARRAY JOIN 的假表用位置而非名字识别(validator.rs:650),优雅地解决了「同名列 vs 表」的歧义。
  • 可信/公开分级施加资源上限。 同一引擎、同一校验,靠 SqlQuerySource 决定是否加内存闸(sql/mod.rs:34),公开面才付防 OOM 的代价。

7. 边界与局限

  • 只支持单条 SELECT。 多语句、非 Query 语句一律拒(validator.rs:470)。
  • 白名单是硬编码的,且要四处手动同步。 加一张可查的表/列,得同时改注册表、前端 tableSchemas、MCP docstring、CLI schema——漏一处就「查得到但看不见」。
  • sql_to_json 是尽力而为、非安全边界。 复杂表达式一律退化成 raw 指标(sql_to_json.rs:189),不保证完美往返;它信任输入已被别处校验。
  • 改写只注入 project_id 当前 commit 里没有额外的时间边界注入(见 3.3 的诚实提示);时间过滤完全依赖用户自己在 WHERE 里写。
  • HYBRID 部署下资源上限不覆盖。 上限只在 CLOUD 路径生效;HYBRID 会把原始查询转发给远端 data plane 自行执行(见仓库 SQL 端点说明)。
  • 依赖 _v0 视图的正确性。 沙箱只保证「每张表都被换成带 project_id 的视图函数」;真正的 project 过滤发生在视图体内(04 章)。视图定义若错,过滤就失效——这层只负责「一定调到视图」,不负责视图本身对不对。

8. 横向对比

本章讲的是同一条数据流水线的最后一环——读侧。它与同组其它章的关系:

全景与阅读顺序见 index.md


9. 代码地图(导航索引)

主题文件路径符号名
引擎门面 + 三方法app-server/src/query_engine/mod.rsQueryEngine / validate_query / sql_to_json / json_to_sql
校验结果枚举app-server/src/query_engine/mod.rs:16QueryEngineValidationResult
校验主流水线app-server/src/query_engine/validator.rs:462validate_and_secure_query
SELECT-only 闸app-server/src/query_engine/validator.rs:556validate_security
函数黑名单 + 前缀app-server/src/query_engine/validator.rs:28blocked_functions / BLOCKED_FUNCTION_PREFIXES / is_blocked_function
黑名单扫描器app-server/src/query_engine/validator.rs:681BlockedScanner
表/列白名单app-server/src/query_engine/validator.rs:227TableRegistry::new / is_table_allowed
表列校验 + 拒 project_idapp-server/src/query_engine/validator.rs:716TableColumnChecker
project 视图改写app-server/src/query_engine/validator.rs:820ViewRewriter
兜底复查app-server/src/query_engine/validator.rs:533validate_post_rewrite / PostRewriteChecker
CTE 影子表防护app-server/src/query_engine/validator.rs:518validate_cte_names
剥离用户 SETTINGSapp-server/src/query_engine/validator.rs:908strip_settings
IN {placeholder} 解析补丁app-server/src/query_engine/validator.rs:125parse_clickhouse_sql / wrap_in_placeholder_lists
ARRAY JOIN 假表识别app-server/src/query_engine/validator.rs:650collect_array_join_spans
JSON → SQLapp-server/src/query_engine/json_to_sql.rs:58convert_json_to_sql
raw 表达式安全边界app-server/src/query_engine/json_to_sql.rs:272validate_raw_expression
SQL → JSONapp-server/src/query_engine/sql_to_json.rs:21convert_sql_to_json
中间数据模型app-server/src/query_engine/types.rs:60QueryStructure
前端可信路由app-server/src/routes/sql.rs:71execute_sql_query / validate_sql_query / sql_to_json / json_to_sql
公开 + CLI 路由app-server/src/api/v1/sql.rs:66handle_sql_query
校验→执行编排app-server/src/sql/mod.rs:104execute_sql_query / SqlQuerySource
校验器测试(拒/改写实例)app-server/src/query_engine/validator/tests.rstest_*