QueryBuilder 与参数
主线位置:查询主线上
ConnectionTrait::query_one的第一步db_backend.build(stmt)。sea-orm 按连接的后端选PostgresQueryBuilder,调SelectStatement::build,QueryBuilder的默认方法从prepare_select_statement开始逐个子句往一个SqlWriterValues里写:标识符加",值换成$1、$2… 并收进Values。得到的(String, Values)包成 sea-orm 的Statement,下一页再交给 sqlx。
flowchart TB b["build(Postgres)"] --> w1["SqlWriterValues"] t["to_string(Postgres)"] --> w2["String"] w1 --> p["逐子句 prepare"] w2 --> p p --> pe["prepare_expr 等"] pe --> pv["prepare_value"] pv --> pp["push_param"] pp -->|SqlWriterValues| ph["写 $N 收 Value"] pp -->|String| inl["write_value 内联"]
build 和 to_string 是同一条路
QueryStatementWriter 的两个方法只差在写进哪种 writer:
fn to_string<T: QueryBuilder>(&self, query_builder: T) -> String {
let mut sql = String::with_capacity(256);
self.build_collect_any_into(&query_builder, &mut sql);
sql
}
// ...
fn build<T: QueryBuilder>(&self, query_builder: T) -> (String, Values) {
let (placeholder, numbered) = query_builder.placeholder();
let mut sql = SqlWriterValues::new(placeholder, numbered);
self.build_collect_into(query_builder, &mut sql);
sql.into_parts()
}
SelectStatement 的 build_collect_into 只有一行 query_builder.prepare_select_statement(self, sql),两个方法走完全相同的 prepare_* 代码。区别全在 SqlWriter 的 push_param 上,它有两个实现:
impl SqlWriter for String {
fn push_param<T: QueryBuilder>(&mut self, value: Value, query_builder: &T) {
query_builder.write_value(self, &value).unwrap();
}
// ...
}
// ...
impl SqlWriter for SqlWriterValues {
fn push_param<T: QueryBuilder>(&mut self, value: Value, _: &T) {
self.string.push_str(&self.placeholder);
if self.numbered {
self.counter += 1;
write_int(&mut self.string, self.counter);
}
self.values.push(value)
}
// ...
}
SqlWriterValues 写一个占位符、把值收起来;String 让 QueryBuilder::write_value 把值写成 SQL 字面量。占位符的样子来自 QueryBuilder::placeholder():默认 ("?", false),Postgres 覆盖成 ("$", true),numbered 为真时 counter 加一再写数字,$N 的编号就是值被访问的顺序。
sea-query 在 crate 文档里对两者的定位写得很直接(src/lib.rs):「build builds a SQL statement as string and parameters to be passed to the database driver through the binary protocol. This is the preferred way as it has less overhead and is more secure.」「to_string builds a SQL statement as string with parameters injected. This is good for testing and debugging.」sea-orm 执行语句时只用 build。
prepare_select_statement:按子句顺序写
QueryBuilder::prepare_select_statement 是一个默认方法,按 SQL 的子句顺序逐个检查字段(中间几段结构相同,省略):
fn prepare_select_statement(&self, select: &SelectStatement, sql: &mut impl SqlWriter) {
if let Some(with) = &select.with {
self.prepare_with_clause(with, sql);
}
sql.write_str("SELECT ").unwrap();
if let Some(distinct) = &select.distinct {
self.prepare_select_distinct(distinct, sql);
sql.write_str(" ").unwrap();
}
let mut selects = select.selects.iter();
join_io!(
selects,
expr,
join {
sql.write_str(", ").unwrap();
},
do {
self.prepare_select_expr(expr, sql);
}
);
// ... into、FROM(每个表之后 prepare_index_hints,最后 prepare_table_sample)
for expr in &select.join {
sql.write_str(" ").unwrap();
self.prepare_join_expr(expr, sql);
}
self.prepare_condition(&select.r#where, "WHERE", sql);
// ... GROUP BY、HAVING、UNION、ORDER BY
self.prepare_select_limit_offset(select, sql);
if let Some(lock) = &select.lock {
sql.write_str(" ").unwrap();
self.prepare_select_lock(lock, sql);
}
// ... WINDOW
}
每个子句是一个可覆盖的 prepare_* 钩子,方言只需要覆盖和通用写法不同的那几个。join_io! 是一个宏:迭代器非空时先执行 first(比如写 FROM),元素之间执行 join,空的时候什么都不写,所以没有 FROM 的 SELECT 1 不会多出一个关键字。整条 SQL 一次写进同一个 writer,没有中间的 Vec<String>。条件部分由 prepare_condition 把 Condition 折成一个 Expr 再交给 prepare_expr;括号由 binary_expr 按运算符优先级决定加不加。
prepare_select_limit_offset 说明了 LIMIT 为什么是参数:
fn prepare_select_limit_offset(&self, select: &SelectStatement, sql: &mut impl SqlWriter) {
if let Some(limit) = &select.limit {
sql.write_str(" LIMIT ").unwrap();
self.prepare_value(limit.clone(), sql);
}
if let Some(offset) = &select.offset {
sql.write_str(" OFFSET ").unwrap();
self.prepare_value(offset.clone(), sql);
}
}
它调的是 prepare_value,和 WHERE 里的值一样。prepare_value 是 trait 里唯一没有默认实现的写值方法,每个方言自己实现;Postgres 的实现除了枚举都是 sql.push_param(value, self as _)。
PostgresQueryBuilder 覆盖了什么
PostgresQueryBuilder 是一个单元结构体,方言差异分散在几个 trait 的实现里:
| 位置 | 覆盖 | 效果 |
|---|---|---|
placeholder | ("$", true) | $1、$2… |
QuotedBuilder::quote | Quote(b'"', b'"') | 标识符用 " 括起来 |
EscapeBuilder | Postgres 自己的转义表 | 见下文 |
write_string_quoted | 需要转义时写 E'...' | 见下文 |
write_bytes | '\x0A0B' | bytea 的十六进制字面量(默认是 x'0A0B') |
prepare_bin_oper | PgBinOper 各分支 | ILIKE、@>、<@、->、->>、~*、&& 等 |
prepare_function_name | PgFunc 各分支 | TO_TSQUERY、ARRAY_AGG、PG_ADVISORY_LOCK 等 |
if_null_function | COALESCE | 默认是 IFNULL |
prepare_select_distinct | DISTINCT ON (...) | |
prepare_order_expr | NULLS LAST / NULLS FIRST | |
prepare_value | 枚举值 | 参数按字符串绑,后面接 ::"类型名" |
prepare_expr | Expr::AsEnum | CAST(... AS "类型名") |
RETURNING 不在表里:它是 QueryBuilder 的默认实现 prepare_returning,Postgres 和 SQLite 直接用,反倒是 MySQL 覆盖成了空函数。" 也是 QueryBuilder 文件里的默认 QUOTE,Postgres 再声明一次是因为 QuotedBuilder::quote 没有默认实现。
哪些值变成 $N,哪些写进 SQL
Expr::Value 走 prepare_value,成为参数;但值也能从别的路进 SQL。一条把几种写法放在一起的语句(src/bin/params.rs,M2 Max、macOS、rustc 1.99.0,sea-query =1.0.2,不连库):
use sea_query::{Cond, Expr, ExprTrait, Order, PostgresQueryBuilder, Query, Values};
// Which values become $N parameters and which are written into the SQL text.
fn main() {
let q = Query::select()
.column("id")
.from("cake")
.and_where(Expr::col("name").like("%choc%"))
.and_where(Expr::col("id").is_in([1, 2]))
.and_where(Expr::cust("price > 0"))
.and_where(Expr::cust_with_values("price < $1", [100]))
.and_where(Expr::col("id").is_in(Vec::<i32>::new()))
.cond_where(Cond::any())
.order_by("id", Order::Field(Values(vec![3.into(), 1.into()])))
.limit(10)
.offset(20)
.to_owned();
let (sql, values) = q.build(PostgresQueryBuilder);
println!("build : {sql}");
println!("values : {:?}", values.0);
println!("to_string: {}", q.to_string(PostgresQueryBuilder));
}
build : SELECT "id" FROM "cake" WHERE "name" LIKE $1 AND "id" IN ($2, $3) AND (price > 0) AND (price < $4) AND $5 = $6 AND FALSE ORDER BY CASE WHEN "id"=3 THEN 0 WHEN "id"=1 THEN 1 ELSE 2 END LIMIT $7 OFFSET $8
values : [String(Some("%choc%")), Int(Some(1)), Int(Some(2)), Int(Some(100)), Int(Some(1)), Int(Some(2)), BigUnsigned(Some(10)), BigUnsigned(Some(20))]
to_string: SELECT "id" FROM "cake" WHERE "name" LIKE '%choc%' AND "id" IN (1, 2) AND (price > 0) AND (price < 100) AND 1 = 2 AND FALSE ORDER BY CASE WHEN "id"=3 THEN 0 WHEN "id"=1 THEN 1 ELSE 2 END LIMIT 10 OFFSET 20
逐项对照:
| 写法 | build 的输出 | 原因 |
|---|---|---|
like("%choc%")、is_in([1, 2]) | $1;($2, $3) | Expr::Value → prepare_value |
Expr::cust("price > 0") | 原样 | Expr::Custom 直接 write_str |
Expr::cust_with_values("price < $1", [100]) | $4 | 模板原样写,$1 换成值,值走 prepare_expr,按整条语句重新编号 |
is_in 空列表 | $5 = $6,值 1 和 2 | prepare_expr_common 把 IN () 换成 binary_expr(1, =, 2),这两个数也是 Value |
cond_where(Cond::any()) | FALSE | 空 Any 是 Expr::Constant,prepare_constant 内联 |
Order::Field([3, 1]) | "id"=3、"id"=1 | prepare_field_order 调的是 write_value,不是 prepare_value |
limit(10)、offset(20) | $7、$8,BigUnsigned | limit 收 u64,存成 Value::BigUnsigned |
所以「build 的 SQL 里没有用户数据」只对 Expr::Value 成立。Order::Field 的值在 build 里也是内联的,靠下面的转义保证安全;Expr::Custom 和 cust_with_values 的模板根本不经过任何处理。最后两个参数是 BigUnsigned,到 sqlx 那边会被转成 i64 绑定(见绑到 sqlx)。
cust_with_values 的模板由 prepare_expr_common 用一个能识别引号的 tokenizer 扫一遍:
Expr::CustomWithExpr(expr, values) => {
let (placeholder, numbered) = self.placeholder();
let mut tokenizer = Tokenizer::new(expr)
.for_query_builder(self)
.iter()
.peekable();
let mut count = 0;
while let Some(token) = tokenizer.next() {
match token {
Token::Punctuation(mark) if mark == placeholder => match tokenizer.peek() {
Some(Token::Punctuation(next_mark)) if next_mark == &placeholder => {
sql.write_str(next_mark).unwrap();
tokenizer.next();
}
Some(Token::Unquoted(tok)) if numbered => {
if let Ok(num) = tok.parse::<usize>() {
self.prepare_expr(&values[num - 1], sql);
}
tokenizer.next();
}
_ => {
self.prepare_expr(&values[count], sql);
count += 1;
}
},
_ => sql.write_str(token.as_str()).unwrap(),
};
}
}
模板里的占位符是当前方言的占位符:Postgres 下只认 $。$ 后面跟数字就取第 N 个值,$$ 写出一个 $,引号里的内容是一个整体 token,不会被替换。values[num - 1] 不检查越界,模板写了 $3 却只给两个值就是 panic。
? 会怎样?cust_with_values 的文档例子里,MySQL 和 SQLite 用 ?,Postgres 用 $1。把 ? 写给 Postgres(src/bin/cust.rs,平台同上):
use sea_query::{Expr, PostgresQueryBuilder, Query};
// How cust_with_values rewrites placeholders on Postgres.
fn main() {
let cases = [
("$2 * $1", Expr::cust_with_values("$2 * $1", [3, 2])),
("? * ?", Expr::cust_with_values("? * ?", [3, 2])),
("'$1' || $1", Expr::cust_with_values("'$1' || $1", ["x"])),
("$$ $1", Expr::cust_with_values("$$ $1", ["x"])),
];
let user = "x' or '1'='1";
for (label, expr) in cases {
let (sql, values) = Query::select().expr(expr).build(PostgresQueryBuilder);
println!("{label:<12} -> {sql:<22} {:?}", values.0);
}
// Expr::cust takes any Into<Cow<'static, str>>, so a format!-ed String compiles.
let q = Query::select().column("id").from("t").and_where(Expr::cust(format!("name = '{user}'"))).to_owned();
println!("cust(format!) -> {:?}", q.build(PostgresQueryBuilder));
}
第二行 ? * ? 的 build 结果里,Values 有几个值?
最后一行和 ? 无关,它说明 Expr::cust 的参数类型 T: Into<Cow<'static, str>> 收 String,format! 拼出来的字符串能直接放进去。sqlx 0.9 用 SqlSafeStr 在类型上拦住了 query(&format!(..))(Query、QueryAs 与 SqlSafeStr),sea-query 这一层没有对应的检查,sea-orm 把 SQL 交给 sqlx 时又统一套了 AssertSqlSafe,所以这道门在 sea-orm 里不起作用。往 Expr::cust 里放的只能是代码里写死的 SQL。
to_string 怎么转义
to_string 把值写成字面量,安全性取决于两处转义:标识符和字符串。
标识符由 QuotedBuilder::prepare_iden 写,build 和 to_string 都经过它:
fn prepare_iden(&self, iden: &DynIden, sql: &mut impl SqlWriter) {
let q = self.quote();
let qq = q.1 as char;
sql.write_char(q.left()).unwrap();
match &iden.0 {
Cow::Borrowed(s) => sql.write_str(s).unwrap(),
Cow::Owned(s) => {
for char in s.chars() {
if char == qq {
sql.write_char(char).unwrap()
}
sql.write_char(char).unwrap()
}
}
};
sql.write_char(q.right()).unwrap();
}
这就是上一页 Cow 两个分支的用处:Borrowed 的名字已经确认只含 [A-Za-z0-9_],原样写;Owned 的逐字检查,遇到右引号 " 就写两遍,这是 SQL 标准的标识符转义。
字符串由 Postgres 的 write_string_quoted 写,它先问 needs_escape:
// https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-BACKSLASH-TABLE
impl EscapeBuilder for PostgresQueryBuilder {
fn needs_escape(&self, s: &str) -> bool {
s.chars().any(|c| match c {
'\x08' | '\x0C' | '\n' | '\r' | '\t' | '\\' | '\'' | '\0' => true,
c if c.is_ascii_control() => true,
_ => false,
})
}
fn write_escaped(&self, buffer: &mut impl Write, string: &str) {
for c in string.chars() {
match c {
'\x08' => buffer.write_str(r#"\b"#),
'\x0C' => buffer.write_str(r#"\f"#),
'\n' => buffer.write_str(r"\n"),
'\r' => buffer.write_str(r"\r"),
'\t' => buffer.write_str(r"\t"),
'\\' => buffer.write_str(r#"\\"#),
'\'' => buffer.write_str(r#"\'"#),
'\0' => buffer.write_str(r#"\0"#),
c if c.is_ascii_control() => write!(buffer, "\\{:03o}", c as u32),
_ => buffer.write_char(c),
}
.unwrap();
}
}
// ...
}
fn write_string_quoted(&self, string: &str, buffer: &mut impl Write) {
if self.needs_escape(string) {
buffer.write_str("E'").unwrap();
} else {
buffer.write_str("'").unwrap();
}
self.write_escaped(buffer, string);
buffer.write_str("'").unwrap();
}
需要转义就用 E'...'。普通的 '...' 里反斜杠的含义取决于服务端的 standard_conforming_strings,E'...' 里反斜杠总是转义符,所以 \' 在任何设置下都表示一个单引号。没有需要转义的字符时用普通字符串,这时里面没有 ' 也没有 \,同样和设置无关。
\z 在 Postgres 里只是一个 z在这之前 PostgresQueryBuilder 只有一行 impl EscapeBuilder for PostgresQueryBuilder {},用的是 EscapeBuilder 在 backend/mod.rs 里的默认转义表(MySQL 今天仍用它),它把 0x1A 写成 MySQL 的转义序列 \z。#694 指出「this is not a valid escape sequence in postgresql」。Postgres 遇到不认识的反斜杠序列就取后面那个字符本身:select E'x\zy' 读回来是 "xzy"(src/bin/zesc.rs),0x1A 变成了字母 z。#984(2025-09 合并,sea-query 仓库里最早包含它的 tag 是 1.0.0-rc.28,更早的 rc 没有打 tag)给 Postgres 写了上面这份单独的实现,表头注释直接链到 Postgres 文档的反斜杠转义表,其余控制字符一律写成三位八进制 \ooo。PR 说明里写着「I didn’t escape Unicode because Postgres can handle Unicode input directly.」下面实验的第四行就是 0x1A 现在的写法 \032。
实验:几种难写的字符串,用 to_string 写成 SQL,再交给 Postgres 执行,读回来比较;别名故意带一个 "(src/bin/escape.rs,Postgres 16.14,连接的 search_path 设到自己的 schema):
use sea_orm::{ConnectionTrait, DbBackend, Statement};
use sea_query::{Alias, Expr, PostgresQueryBuilder, Query};
// What to_string() writes for awkward identifiers and strings, and whether
// Postgres reads the inlined literal back as the same string.
#[tokio::main]
async fn main() -> Result<(), sea_orm::DbErr> {
let db = exp_seaorm_seaquery::connect(false).await?;
let inputs = ["it's", r"C:\dir", "a\nb", "x\u{1a}y", "'; drop table t; --"];
for s in inputs {
let q = Query::select().expr_as(Expr::val(s), Alias::new("v\"x")).to_owned();
let sql = q.to_string(PostgresQueryBuilder);
let row = db.query_one_raw(Statement::from_string(DbBackend::Postgres, sql.clone())).await?;
let back: String = row.expect("one row").try_get("", "v\"x")?;
println!("{sql:<45} same={}", back == s);
}
Ok(())
}
SELECT E'it\'s' AS "v""x" same=true
SELECT E'C:\\dir' AS "v""x" same=true
SELECT E'a\nb' AS "v""x" same=true
SELECT E'x\032y' AS "v""x" same=true
SELECT E'\'; drop table t; --' AS "v""x" same=true
五个字符串都原样读回,别名里的 " 写成了 ""。
转义只覆盖经过 write_value 和 prepare_iden 的东西。下面这些直接 write_str,build 和 to_string 都不处理:
| 来源 | 写法 |
|---|---|
Expr::cust、cust_with_values 的模板 | Expr::Custom / CustomWithExpr |
Func::cust("name") 的函数名 | prepare_function_name_common 写 iden.0 |
Expr::custom_keyword | Keyword::Custom,prepare_keyword 写 iden.0 |
as_enum 和枚举值的类型名 | Postgres 的 prepare_expr 和 write_enum_type_hint 在 " 中间直接写类型名 |
最后一项:类型名虽然是 DynIden,却没有走 prepare_iden,Owned 里的 " 不会被双写。下面实验的最后两行能看到 as_enum("my\"enum") 写出的是 "my"enum"。类型名一般来自实体定义(DeriveActiveEnum 的 enum_name),不是用户输入,但这意味着「DynIden 一定被转义」不成立。
to_string 的输出不是同一条语句
就算转义全对,to_string 的输出和 build 发出去的也不是同一条语句:参数带类型,字面量不带。把同一个 SelectStatement 分别用两种方式执行,问 Postgres pg_typeof(src/bin/typeof.rs):
use sea_orm::{ConnectionTrait, DbBackend, Statement};
use sea_query::{Alias, Expr, ExprTrait, Func, PostgresQueryBuilder, Query, Value};
// The same SelectStatement, sent once via build() ($1 + bound value) and once
// via to_string() (value inlined): what type does Postgres see?
#[tokio::main]
async fn main() -> Result<(), sea_orm::DbErr> {
let db = exp_seaorm_seaquery::connect(false).await?;
let ts = chrono::DateTime::parse_from_rfc3339("2026-10-24T08:00:00Z").unwrap().to_utc();
let values: Vec<Value> = vec![
"1".into(),
serde_json::json!({"a": 1}).into(),
ts.into(),
Value::Int(None),
f64::NAN.into(),
];
for (i, v) in values.into_iter().enumerate() {
let q = Query::select()
.expr_as(Func::cust("pg_typeof").arg(Expr::val(v)).cast_as("text"), Alias::new("t"))
.to_owned();
// A comment makes each SQL text distinct, so no cached statement is reused.
let (sql, vals) = q.build(PostgresQueryBuilder);
let built = Statement::from_sql_and_values(DbBackend::Postgres, format!("{sql} -- {i}"), vals);
let inlined = q.to_string(PostgresQueryBuilder);
let a = typ(&db, built).await;
let b = typ(&db, Statement::from_string(DbBackend::Postgres, inlined.clone())).await;
println!("{inlined}\n build: {a}\n to_string: {b}");
}
let e = Query::select().expr(Expr::val("x").as_enum(Alias::new("my\"enum"))).to_owned();
println!("as_enum build : {}", e.build(PostgresQueryBuilder).0);
println!("as_enum to_string: {}", e.to_string(PostgresQueryBuilder));
Ok(())
}
async fn typ(db: &sea_orm::DatabaseConnection, stmt: Statement) -> String {
match db.query_one_raw(stmt).await {
Ok(Some(row)) => row.try_get::<String>("", "t").unwrap_or_else(|e| e.to_string()),
Ok(None) => "no row".into(),
Err(e) => e.to_string(),
}
}
SELECT CAST(pg_typeof('1') AS text) AS "t"
build: text
to_string: unknown
SELECT CAST(pg_typeof('{"a":1}') AS text) AS "t"
build: jsonb
to_string: unknown
SELECT CAST(pg_typeof('2026-10-24 08:00:00.000000 +00:00') AS text) AS "t"
build: timestamp with time zone
to_string: unknown
SELECT CAST(pg_typeof(NULL) AS text) AS "t"
build: integer
to_string: unknown
SELECT CAST(pg_typeof(NaN) AS text) AS "t"
build: double precision
to_string: Query Error: error returned from database: column "nan" does not exist at line 3722
as_enum build : SELECT CAST($1 AS "my"enum")
as_enum to_string: SELECT CAST('x' AS "my"enum")
build的参数在Parse里带着类型 OID(text、jsonb、timestamptz、int4、float8),哪怕值是 NULL。类型从哪来见下一页。to_string写出的'...'和NULL是unknown类型的字面量,由上下文推断。放在col = '...'这种位置通常能推断对,放进函数参数、CASE、UNION里就不一定。JSON 只是一段文本,时间只是一个字符串。f64::NAN被写成裸的NaN,Postgres 把它当成列名nan。浮点数的特殊值只有作为参数时才是对的。
sea-orm 的 debug-print 打印的 Statement 走的是同一条路:Statement 的 Display 调 sea-query 的 inject_parameters,把 Values 按 $N 用 write_value 填回 SQL(下一页有实际输出)。这些字符串都是给人看的:调试日志、测试断言。要执行,用 build 的结果;sea-query 文档对 to_string 的定位也只是「good for testing and debugging」。
动脑筋答案
$2 * $1 -> SELECT $1 * $2 [Int(Some(2)), Int(Some(3))]
? * ? -> SELECT ? * ? []
'$1' || $1 -> SELECT '$1' || $1 [String(Some("x"))]
$$ $1 -> SELECT $ $1 [String(Some("x"))]
cust(format!) -> ("SELECT \"id\" FROM \"t\" WHERE name = 'x' or '1'='1'", Values([]))
? * ?:零个。Postgres 的占位符是$,?只是一个普通的标点 token,原样写出;两个值一个都没用上,也没有报错。这条 SQL 发给 Postgres 得到42601、syntax error at or near "*"(src/bin/qmark.rs把它真的发了一次),问题要到执行时才暴露。$2 * $1:模板里的编号只用来挑值,写出来的编号按访问顺序重排,先访问的values[1](2)成了$1。'$1' || $1:引号里的$1是字符串字面量的一部分,没有被替换。$$ $1:$$写出一个$。- 最后一行:
format!拼进Expr::cust的用户输入原样进了 SQL,Values是空的,build也救不了它。
小结
build和to_string跑同一套prepare_*,区别只在SqlWriter::push_param:SqlWriterValues写$N收值,String把值内联。prepare_select_statement按子句顺序写,每个子句是可覆盖的钩子;Postgres 覆盖占位符、转义、E'...'、PgBinOper、COALESCE、DISTINCT ON、枚举的::"类型";RETURNING是默认实现。LIMIT/OFFSET是参数(BigUnsigned);Expr::cust原样写;cust_with_values在 Postgres 下只认$N,写?会静默丢掉值;Order::Field和空条件的TRUE/FALSE在build里也是内联的。to_string双写标识符里的",需要时用E'...'转义字符串,实验中带'、\、换行、0x1A的字符串都能原样读回;但枚举类型名、Expr::cust、Func::cust不转义,输出的字面量也没有参数类型(unknown、NaN变列名)。只用来看,不用来执行。