contains("_") 匹配了每一行
主线位置:查询主线的第一段,
filter里的条件。Column::Name.contains(s)在构造Select时就把s拼成了LIKE的模式字符串,之后它作为$1的值原样发出去。值是参数,不会造成 SQL 注入;但模式里的%、_、\由 Postgres 的LIKE解释,不是按字面匹配。条件方法的全貌见列与值,值怎么变成$N见 QueryBuilder 与参数。
flowchart TB inp["用户输入 s"] --> fmt["format! 拼出模式"] fmt --> val["$1 = 模式字符串"] val --> like["Postgres LIKE"] like --> pct["% 任意串"] like --> und["_ 任意一个字符"] like --> bs["反斜杠 转义下一个字符"] esc["先转义, 再 LikeExpr escape"] --> lit["按字面匹配"]
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sea-orm = "=2.0.4",对照组 =1.1.20。表:
CREATE TABLE product (id serial PRIMARY KEY, name text NOT NULL);
INSERT INTO product (name) VALUES ('100% cotton'), ('100 pcs'), ('snake_case'), ('snakeXcase'), ('C:\temp');
最后一行的值里是一个字面的反斜杠。
现象
src/bin/like.rs 把几个搜索词原样交给 contains,像一个搜索框那样:
async fn names(db: &sea_orm::DatabaseConnection, cond: sea_orm::sea_query::Expr) -> Result<Vec<String>, DbErr> {
Ok(Product::find().filter(cond).order_by_asc(Column::Id).all(db).await?.into_iter().map(|m| m.name).collect())
}
// ...
println!("--- user input passed straight in");
for q in ["_", "%", "100%", "snake_case", r"C:\temp"] {
println!("contains({q:?}) -> {:?}", names(&db, Column::Name.contains(q)).await?);
}
--- user input passed straight in
contains("_") -> ["100% cotton", "100 pcs", "snake_case", "snakeXcase", "C:\\temp"]
contains("%") -> ["100% cotton", "100 pcs", "snake_case", "snakeXcase", "C:\\temp"]
contains("100%") -> ["100% cotton", "100 pcs"]
contains("snake_case") -> ["snake_case", "snakeXcase"]
...
- 搜
_和%返回整张表:模式是%_%(至少一个字符)和%%%(任意串)。 - 搜
100%多出了100 pcs,搜snake_case多出了snakeXcase。 - 没有报错,只是结果多了。省略的那一行留给动脑筋。
出处
fn contains<T>(&self, s: T) -> Expr
where
T: Into<String>,
{
let pattern = format!("%{}%", s.into());
Expr::col(self.as_column_ref()).like(pattern)
}
starts_with 和 ends_with 一样,只是 % 放在一边。三个方法都没有转义,文档注释里写的是「This is a simplified shorthand for a more general like method. Use like if you need something more complex, like specifying an escape character.」。
发出去的是什么(src/bin/like_sql.rs,sqlx 日志加 debug-print,debug-print 那一行是值内联后的样子):
DEBUG sea_orm::driver::sqlx_postgres: SELECT "product"."id", "product"."name" FROM "product" WHERE "product"."name" LIKE '%100%%'
INFO sqlx::query: summary="SELECT \"product\".\"id\", \"product\".\"name\" FROM …" db.statement="\n\nSELECT \"product\".\"id\", \"product\".\"name\" FROM \"product\" WHERE \"product\".\"name\" LIKE $1\n" rows_affected=2 rows_returned=2 elapsed=3.502917ms elapsed_secs=0.003502917
模式 %100%% 作为 $1 发出,第二个 % 在 LIKE 里就是通配符。
ColumnTrait::like 到 1.1.9 才能带 ESCAPEsea-query 从 0.26.0 起就有 LikeExpr::escape(sea-query#352,CHANGELOG 记为「LIKE ESCAPE support」),但 sea-orm 的 ColumnTrait::like / not_like 原来的签名是 T: Into<String>,只收一个字符串,用列方法写不出 ESCAPE。#2549(「Accept LikeExpr in like and not_like」,首次发布于 1.1.9)把参数改成 T: IntoLikeExpr,PR 说明是「Allow to specify an escape character in like and not_like」,「The old signatures were insufficiently generic and didn't accept LikeExpr」;因为 IntoLikeExpr 对所有 T: Into<String> 都有实现,这个改动不破坏旧代码。上面那句「Use like if you need something more complex, like specifying an escape character」也是这个 PR 加在 contains 等三个方法上的。contains 本身没有改,仍然是 Into<String> 加 format!。本站用的 1.1.20 已经包含这个改动。
修法
先把输入里的 \、%、_ 转义,再用 like 加 LikeExpr::escape 明确写出转义字符:
use sea_orm::sea_query::LikeExpr;
/// Escape LIKE metacharacters so user input matches literally (escape char: backslash).
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '\\' | '%' | '_') {
out.push('\\');
}
out.push(c);
}
out
}
// ...
for q in ["_", "%", "100%", "snake_case", r"C:\temp"] {
let pattern = format!("%{}%", escape_like(q));
let cond = Column::Name.like(LikeExpr::new(pattern).escape('\\'));
println!("like({q:?}) -> {:?}", names(&db, cond).await?);
}
--- escaped
like("_") -> ["snake_case"]
like("%") -> ["100% cotton"]
like("100%") -> ["100% cotton"]
like("snake_case") -> ["snake_case"]
like("C:\\temp") -> ["C:\\temp"]
每个搜索词都只匹配字面包含它的那一行。ilike / not_like / not_ilike 也收 IntoLikeExpr,用法相同。转义字符也可以换成别的(例如下面的 !),escape_like 里插入的前缀和要转义的字符要跟着换。
ESCAPE 子句发出去的样子(同一个 like_sql.rs):
DEBUG sea_orm::driver::sqlx_postgres: SELECT "product"."id", "product"."name" FROM "product" WHERE "product"."name" LIKE E'%100\\%%' ESCAPE E'\\'
INFO sqlx::query: summary="SELECT \"product\".\"id\", \"product\".\"name\" FROM …" db.statement="\n\nSELECT \"product\".\"id\", \"product\".\"name\" FROM \"product\" WHERE \"product\".\"name\" LIKE $1 ESCAPE E'\\\\'\n" rows_affected=1 rows_returned=1 elapsed=1.466292ms elapsed_secs=0.001466292
DEBUG sea_orm::driver::sqlx_postgres: SELECT "product"."id", "product"."name" FROM "product" WHERE "product"."name" LIKE '%100!%%' ESCAPE '!'
INFO sqlx::query: summary="SELECT \"product\".\"id\", \"product\".\"name\" FROM …" db.statement="\n\nSELECT \"product\".\"id\", \"product\".\"name\" FROM \"product\" WHERE \"product\".\"name\" LIKE $1 ESCAPE '!'\n" rows_affected=1 rows_returned=1 elapsed=646.625µs elapsed_secs=0.000646625
模式仍然是参数 $1,转义字符却直接写进了 SQL:From<LikeExpr> for Expr 把它做成 BinOper::Escape 右边的 Expr::Constant,常量不变成参数。反斜杠被写成 E'\\'(sqlx 日志的 db.statement 是 Debug 格式,又把每个反斜杠加倍了一次),即 Postgres 的转义字符串语法里的一个反斜杠;换成 ! 就是普通的 '!'。转义字符来自代码而不是用户,写进 SQL 没有问题。
怎么确认没中招
- 在代码里找
.contains(、.starts_with(、.ends_with(、.like(、.ilike(作用在Column上的地方,看参数是不是来自用户输入(搜索框、筛选条件、URL 参数)。是的话就要先转义。 - 给搜索接口加一条测试:搜
_和%,期望只返回字面包含这两个字符的记录。 - 本站:按固定提交
89a61b1在crates/下没有对 sea-orm 列调用这几个方法,也没有手写的LIKE语句,不受影响。
contains(r"C:\temp")。表里确实有一行 C:\temp,它会返回哪些行?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。1.1.20(exp-seaorm-pit1-old/src/bin/like_old.rs,同一个程序换成展开写法的实体)的十行输出和 2.0.4 逐行相同,包括 like(LikeExpr::new(..).escape('\\')) 那一半。
动脑筋答案
contains("C:\\temp") -> []
一行也没有,连 C:\temp 自己都查不到。模式是 %C:\temp%。Postgres 的 LIKE 不写 ESCAPE 时默认以反斜杠为转义字符,\t 的意思是「字面的 t」,于是模式等价于 %C:temp%,而表里的值中间有一个反斜杠,匹配不上。所以不转义的代价不只是「结果多了」:输入里带反斜杠时结果会变少。转义之后的 like("C:\\temp") 模式是 %C:\\temp%,\\ 是字面的反斜杠,查到了这一行。
小结
contains/starts_with/ends_with是format!加like,不转义%、_、\;值是参数,没有注入问题,但会多匹配,遇到反斜杠还会少匹配。2.0.4 和 1.1.20 一样。- 修法:自己转义
\、%、_,再用Column::X.like(LikeExpr::new(pattern).escape('\\'));ilike等同理。 ESCAPE的字符以常量写进 SQL(ESCAPE E'\\'),模式仍是$1。ColumnTrait::like从 1.1.9(#2549)起收LikeExpr,本站的 1.1.20 可以直接用。