写一个迷你 ORM
主线位置:把三条主线各从零实现一遍。定义主线:一个手写的实体代替
DeriveEntityModel的展开;查询主线:find_by_id(..).one(&pool)从Select到$N的 SQL,再经sqlx::query_with到fetch_optional,最后按列名映射成Model;写入主线:ActiveValue三态决定INSERT的列和UPDATE的SET,RETURNING拿回整行。关系、Loader 和宏不实现。
flowchart TB find["find_by_id 1"] --> sel["Select E"] sel -->|build| st["Statement sql 和 values"] am["ActiveModel"] -->|"get(col)"| av["ActiveValue Value"] av --> upd["update 只写 Set 列"] av --> ins["insert 写有值的列"] upd --> st ins --> st st -->|into_query| q["sqlx query_with"] q --> fetch["fetch_optional 或 fetch_one"] fetch -->|from_row| model[Model]
范围
迷你版只依赖 sqlx 0.9.0(postgres、runtime-tokio)和 tokio,不碰 sea-orm、sea-query 的任何 crate。四个文件,每一块对应 sea-orm 里讲它的那一页:
| 迷你版 | 做到哪一步 | sea-orm 里的对应 |
|---|---|---|
lib.rs(180 行) | Value 三种类型;EntityTrait、ColumnTrait、ActiveModelTrait;ActiveValue;Error 五个分支 | 实体的几个 trait、列与值、ActiveValue 与 ActiveModel |
select.rs(124 行) | Statement(SQL 加参数)、Select 的 filter / order_by / limit / all / one | Select 与过滤、QueryBuilder 与参数、绑定到 sqlx |
write.rs(99 行) | insert、update,都带 RETURNING | insert、update 与 delete |
item.rs(94 行) | 一个五列的实体,全部手写 | DeriveEntityModel 与 dense 格式 |
只支持 Postgres、单列主键,值只有 int4、int8、text 三种。下文只贴关键片段,都是 2 空格缩进的自写代码;引用 sea-orm / sea-query 源码的地方保留原样的 4 空格。实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,表建在 so_handson schema 里:item (id serial primary key, sku text not null unique, name text not null, qty int not null default 0, note text),每次先插入 (1, 'a1', 'apple', 3)、(2, 'b2', 'banana', 5)、(3, 'c3', 'cherry', 7) 三行,和写入一章用的是同一张表。
实体:类型就是表
sea-orm 的 Entity 是一个单元结构体,表名、列、主键都挂在它的 trait 上(实体的几个 trait)。迷你版照这个形状,把 EntityTrait 的七个关联类型缩成两个,再加三个关联常量:
pub trait ColumnTrait: Copy + PartialEq + 'static {
fn name(self) -> &'static str;
fn eq(self, v: impl Into<Value>) -> Cond<Self> {
Cond { col: self, op: "=", value: v.into() }
}
// ... gt、lt、like 同样
}
/// The entity type is the table: a unit struct whose associated items describe it.
pub trait EntityTrait: Sized {
type Column: ColumnTrait;
type Model;
const TABLE: &'static str;
/// Every column, in SELECT / RETURNING order.
const COLUMNS: &'static [Self::Column];
const PRIMARY_KEY: Self::Column;
/// Map one row by column name, like sea-orm's `FromQueryResult`.
fn from_row(row: &PgRow) -> Result<Self::Model, sqlx::Error>;
fn find() -> Select<Self> {
Select::new()
}
fn find_by_id(id: impl Into<Value>) -> Select<Self> {
Self::find().filter(Self::PRIMARY_KEY.eq(id))
}
}
和 sea-orm 的对应:
| 迷你版 | sea-orm 2.0.4 |
|---|---|
const TABLE | EntityName::table_name,加上可选的 schema 名 |
const COLUMNS: &[Column] | Column 派生 EnumIter,用 Column::iter() 遍历 |
const PRIMARY_KEY | PrimaryKey 枚举和 PrimaryKeyToColumn,可以是复合主键 |
fn from_row | Model 实现 FromQueryResult,也按列名取 |
ColumnTrait::eq 返回 Cond<Self> | ColumnTrait::eq 返回 sea-query 的 Expr |
find 和 find_by_id 是默认方法,和 sea-orm 的 EntityTrait::find_by_id 一样只是「find() 加一个主键条件」。手写的 item 实体就是 DeriveEntityModel 会生成的那几样东西的最小子集:一个单元结构体 Entity、一个 Column 枚举、Model、每个字段包一层 ActiveValue 的 ActiveModel,以及几段 match:
impl EntityTrait for Entity {
type Column = Column;
type Model = Model;
const TABLE: &'static str = "item";
const COLUMNS: &'static [Column] =
&[Column::Id, Column::Sku, Column::Name, Column::Qty, Column::Note];
const PRIMARY_KEY: Column = Column::Id;
fn from_row(row: &PgRow) -> Result<Model, sqlx::Error> {
Ok(Model {
id: row.try_get("id")?,
sku: row.try_get("sku")?,
name: row.try_get("name")?,
qty: row.try_get("qty")?,
note: row.try_get("note")?,
})
}
}
Column::name、ActiveModelTrait::get、into_active_model 都是这样按字段逐个写的 match 或结构体字面量。五列的实体手写约 90 行;DeriveEntityModel 与 dense 格式里三列的实体展开约一千行,多出来的是迷你版没有的那些 trait 和方法。
条件与 Select:每个方法改一个子句
条件 Cond<C> 记下列、运算符和值。它对列的类型泛型,Select<E>::filter 只收 Cond<E::Column>:
pub struct Select<E: EntityTrait> {
conds: Vec<Cond<E::Column>>,
orders: Vec<(E::Column, Order)>,
limit: Option<u64>,
entity: PhantomData<E>,
}
impl<E: EntityTrait> Select<E> {
/// Conditions are ANDed, like repeated `filter` in sea-orm.
pub fn filter(mut self, cond: Cond<E::Column>) -> Self {
self.conds.push(cond);
self
}
// ... order_by、limit 同样只是 push / 赋值
}
拿另一个实体的列去过滤 item,编译不过(src/bin/wrong_column.rs,里面另写了一个只有 id 列的 Tag 实体):
error[E0308]: mismatched types
--> src/bin/wrong_column.rs:31:39
|
31 | let _ = item::Entity::find().filter(TagColumn::Id.eq(1));
| ------ ^^^^^^^^^^^^^^^^^^^ expected `Cond<Column>`, found `Cond<TagColumn>`
| |
| arguments to this method are incorrect
|
= note: expected struct `Cond<exp_seaorm_handson::item::Column>`
found struct `Cond<TagColumn>`
sea-orm 的 QueryFilter::filter 收的是任意 F: IntoCondition,不检查列属于哪张表。这不是疏忽:join 之后要按另一张表的列过滤(join 与关联查询),条件就必须能跨表。迷你版没有 join,才能把这一条交给编译器。
build 按子句顺序写 SQL,和 sea-query 的 prepare_select_statement 一样:列、FROM、WHERE、ORDER BY、LIMIT(QueryBuilder 与参数)。值只有一个入口:
impl Statement {
/// The only place a value enters the SQL: as the next `$N`.
pub(crate) fn push_param(&mut self, v: Value) {
self.values.push(v);
self.sql.push('$');
self.sql.push_str(&self.values.len().to_string());
}
}
sea-query 里同样的事由 SqlWriterValues::push_param 做,多一个 numbered 开关给 MySQL 的 ?:
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)
}
build 里写条件和 LIMIT 的两段:
for (i, cond) in self.conds.iter().enumerate() {
st.sql.push_str(if i == 0 { " WHERE " } else { " AND " });
st.push_qualified(E::TABLE, cond.col.name());
st.sql.push(' ');
st.sql.push_str(cond.op);
st.sql.push(' ');
st.push_param(cond.value.clone());
}
// ... ORDER BY
if let Some(n) = self.limit {
// A parameter, not a literal, as sea-query does; Postgres has no u64.
let n64 = i64::try_from(n).map_err(|_| Error::LimitTooLarge(n))?;
st.sql.push_str(" LIMIT ");
st.push_param(Value::BigInt(Some(n64)));
}
列名一律写成 "表"."列",push_qualified 调 quote:前后加双引号,名字里的 " 写成两个。LIMIT 和 sea-query 的 prepare_select_limit_offset 一样走 push_param,变成一个 int8 参数。sea-query 那边 limit 存的是 Value::BigUnsigned,到 sea-query-sqlx 绑定时才 i64::try_from(..).unwrap();迷你版把这次转换提前到 build,超出范围返回 LimitTooLarge,后面「两处不同」里有两边的实际表现。
交给 sqlx:Statement 到 query_with
Statement 变成 sqlx 的查询只要几行:
/// Hand over to sqlx: the SQL was built from identifiers we quoted and
/// placeholders, never from values, which is what AssertSqlSafe asserts.
pub(crate) fn into_query(self) -> Result<Query<'static, Postgres, PgArguments>, Error> {
let mut args = PgArguments::default();
for v in self.values {
v.bind(&mut args)?;
}
Ok(sqlx::query_with(AssertSqlSafe(self.sql), args))
}
这和 sea-orm 驱动里的 sqlx::query_with(AssertSqlSafe(sql), SqlxValues(values)) 是同一个形状(一次 find 的全过程)。sqlx 0.9 的 query_with 只收 SqlSafeStr,String 要包一层 AssertSqlSafe 才能传进去(Query、QueryAs 与 SqlSafeStr);对一个 ORM 来说这个断言成立的前提就是上一节的那条规矩:SQL 里只有引起来的标识符和 $N,没有值。
Value::bind 按分支调 PgArguments::add,Value::Text(None) 也是 add(None::<String>),所以 NULL 照样带着 text 的类型发出去,这一点和 sea-query-sqlx 相同(绑定到 sqlx)。add 返回的编码错误转成 Error::Sqlx(sqlx::Error::Encode(..)),sea-query-sqlx 在同一处写的是 let _ = args.add(..),把它丢掉。
执行和映射:
/// Like sea-orm's `Selector::one`: overrides any limit with 1.
pub async fn one<'e>(self, db: impl PgExecutor<'e>) -> Result<Option<E::Model>, Error> {
let row = self.limit(1).build()?.into_query()?.fetch_optional(db).await?;
let model = row.as_ref().map(E::from_row).transpose()?;
Ok(model)
}
sea-orm 的 Selector::one 也是先 limit(1) 覆盖掉已经设的 LIMIT,再 query_one;驱动里用 fetch_one,把 RowNotFound 改成 Ok(None),迷你版直接用 fetch_optional。db 收 impl PgExecutor<'e>,传 &pool 时每条语句各借一次连接,和 sea-orm 一样(获取连接)。
ActiveValue 与 update
三态是一个枚举,和 sea-orm 的 ActiveValue 同名同序:
pub enum ActiveValue<V> {
Set(V),
Unchanged(V),
#[default]
NotSet,
}
pub trait ActiveModelTrait {
type Entity: EntityTrait;
/// Column-indexed access, the only thing insert / update need.
fn get(&self, col: <Self::Entity as EntityTrait>::Column) -> ActiveValue<Value>;
}
ActiveModel 的每个字段是带具体类型的 ActiveValue<i32>、ActiveValue<Option<String>>,写入代码却要对任意实体工作,所以按列取、取出来统一成 ActiveValue<Value>。sea-orm 的 ActiveModelTrait::get 签名一样,insert / update 都只靠它和列的遍历。
update 先看主键,再扫其余列:
pub async fn update<'e, A: ActiveModelTrait>(
am: &A,
db: impl PgExecutor<'e>,
) -> Result<ModelOf<A>, Error> {
let pk = A::Entity::PRIMARY_KEY;
// Without a key the WHERE would be empty: an UPDATE of the whole table.
let id = match am.get(pk) {
ActiveValue::Set(v) | ActiveValue::Unchanged(v) => v,
ActiveValue::NotSet => return Err(Error::PrimaryKeyNotSet),
};
let mut st = Statement::default();
st.sql.push_str("UPDATE ");
quote(&mut st.sql, A::Entity::TABLE);
st.sql.push_str(" SET ");
let mut assigned = 0;
for &col in A::Entity::COLUMNS {
if col == pk {
continue;
}
if let ActiveValue::Set(v) = am.get(col) {
if assigned > 0 {
st.sql.push_str(", ");
}
quote(&mut st.sql, col.name());
st.sql.push_str(" = ");
st.push_param(v);
assigned += 1;
}
}
if assigned == 0 {
// `UPDATE t SET WHERE ...` is not SQL; read the row back instead, as sea-orm does.
return A::Entity::find_by_id(id).one(db).await?.ok_or(Error::RecordNotFound);
}
st.sql.push_str(" WHERE ");
st.push_qualified(A::Entity::TABLE, pk.name());
st.sql.push_str(" = ");
st.push_param(id);
push_returning::<A::Entity>(&mut st);
let row = st.into_query()?.fetch_optional(db).await?.ok_or(Error::RecordNotUpdated)?;
Ok(A::Entity::from_row(&row)?)
}
逐条对上 sea-orm 的 Update::one 和执行器(代码和三种情况的实验在 update 与 delete):
- 主键
Set或Unchanged都进WHERE,NotSet返回PrimaryKeyNotSet,一条语句都不发。sea-orm 2.0 也是错误,1.1.20 在这里 panic。 - 主键跳过,不进
SET;Unchanged的列不写,只有Set的写。 - 一列都没
Set时不发UPDATE,按主键SELECT一次,查不到是RecordNotFound。 - 有列要写时
UPDATE ... RETURNING一条语句写完并读回整行,没有返回行就是RecordNotUpdated。
WHERE 的参数号在 SET 之后:SET 先写,主键的值后 push_param,所以是 SET "qty" = $1 WHERE "item"."id" = $2,和 sea-orm 的顺序相同。迷你版的 update 是自由函数,不是 am.update(&db),省掉了 trait 里写异步方法的麻烦;sea-orm 的 ActiveModelTrait 是一个 #[async_trait::async_trait] trait,insert / update / save 是它的默认方法。
insert ... RETURNING
pub async fn insert<'e, A: ActiveModelTrait>(
am: &A,
db: impl PgExecutor<'e>,
) -> Result<ModelOf<A>, Error> {
let mut cols = Vec::new();
let mut st = Statement::default();
for &col in A::Entity::COLUMNS {
match am.get(col) {
ActiveValue::Set(v) | ActiveValue::Unchanged(v) => cols.push((col, v)),
ActiveValue::NotSet => {}
}
}
// ... INSERT INTO "item" ("sku", "name") VALUES ($1, $2),没有列时写 DEFAULT VALUES
push_returning::<A::Entity>(&mut st);
let row = st.into_query()?.fetch_one(db).await?;
Ok(A::Entity::from_row(&row)?)
}
sea-orm 的 Insert::one 里是同一个 match:
for col in <A::Entity as EntityTrait>::Column::iter() {
let av = am.take(col);
match av {
ActiveValue::Set(value) | ActiveValue::Unchanged(value) => {
columns.push(col);
values.push(col.save_as(Expr::val(value)));
}
ActiveValue::NotSet => {}
}
}
Set 和 Unchanged 都写,NotSet 交给数据库(serial、DEFAULT)。RETURNING 列出全部列、不带表名,这是 exec_insert_with_returning 在 Postgres 上走的分支;不支持 RETURNING 的数据库它改为先插入、再按 last_insert_id 查一次,迷你版只有 Postgres,没有这条退路。一列都没有时迷你版写 DEFAULT VALUES,sea-query 写的是 VALUES (DEFAULT)(见 insert),这是 SQL 文本上的一处不同,上面的实体碰不到(sku、name 没有默认值)。
跑起来
src/bin/demo.rs:建表,构造一条带两个条件、一个排序和 limit(10) 的查询,打印 build() 的结果后执行;然后是 find_by_id、读出来改一列再 update、三种 update 失败、insert、limit(u64::MAX):
--- build a select
SELECT "item"."id", "item"."sku", "item"."name", "item"."qty", "item"."note" FROM "item" WHERE "item"."qty" > $1 AND "item"."name" LIKE $2 ORDER BY "item"."qty" DESC LIMIT $3
[Int(Some(3)), Text(Some("%an%")), BigInt(Some(10))]
=> [Model { id: 2, sku: "b2", name: "banana", qty: 5, note: None }]
--- find_by_id(1).one
=> Some(Model { id: 1, sku: "a1", name: "apple", qty: 3, note: None })
--- read, change one field, update
=> Model { id: 1, sku: "a1", name: "apple", qty: 4, note: None }
--- nothing Set, id 99
=> Err(RecordNotFound)
--- one field Set, id 99
=> Err(RecordNotUpdated)
--- primary key NotSet
=> Err(PrimaryKeyNotSet) / primary key not set
--- insert, id and qty NotSet
=> Model { id: 4, sku: "d4", name: "date", qty: 0, note: None }
--- limit(u64::MAX)
=> Err(LimitTooLarge(18446744073709551615)) / limit 18446744073709551615 does not fit in int8
- 三个参数依次是条件的
3(int4)、'%an%'(text)和LIMIT的10(int8);条件按filter的调用顺序用AND连起来。 insert只给了sku和name,RETURNING带回了数据库生成的id = 4和默认值qty = 0。- 三种失败:主键
NotSet和limit过大是在build阶段返回的,没有碰数据库;另外两种各发了一条语句。
和 sea-orm 2.0.4 对照
另一个 crate exp-seaorm-handson-cmp 依赖迷你版和 sea-orm 2.0.4(sqlx-postgres、runtime-tokio-rustls、macros),实体用 DeriveEntityModel 定义同一张表。src/bin/compare.rs 对七种操作各跑一次迷你版、一次 sea-orm,需要时在中间用一条不记日志的连接重建表。日志订阅 sqlx::query=debug:sqlx 自己的语句日志默认是 DEBUG(迷你版那几行),sea-orm 把它调成了 INFO(sea-orm 那几行)。
=== 1. filter + order_by + limit, all()
DEBUG sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"qty\" > $1 AND \"item\".\"name\" LIKE $2 ORDER BY \"item\".\"qty\" DESC LIMIT $3\n" rows_affected=1 rows_returned=1 elapsed=2.802292ms elapsed_secs=0.002802292
[mini] [Model { id: 2, sku: "b2", name: "banana", qty: 5, note: None }]
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"qty\" > $1 AND \"item\".\"name\" LIKE $2 ORDER BY \"item\".\"qty\" DESC LIMIT $3\n" rows_affected=1 rows_returned=1 elapsed=2.339834ms elapsed_secs=0.002339834
[sea-orm] [Model { id: 2, sku: "b2", name: "banana", qty: 5, note: None }]
=== 2. find_by_id(1).one()
DEBUG sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=565.625µs elapsed_secs=0.000565625
[mini] Some(Model { id: 1, sku: "a1", name: "apple", qty: 3, note: None })
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=555.75µs elapsed_secs=0.00055575
[sea-orm] Some(Model { id: 1, sku: "a1", name: "apple", qty: 3, note: None })
=== 3. one column Set, update
DEBUG sqlx::query: summary="UPDATE \"item\" SET \"qty\" …" db.statement="\n\nUPDATE \"item\" SET \"qty\" = $1 WHERE \"item\".\"id\" = $2 RETURNING \"id\", \"sku\", \"name\", \"qty\", \"note\"\n" rows_affected=1 rows_returned=1 elapsed=784.417µs elapsed_secs=0.000784417
[mini] Ok(Model { id: 1, sku: "a1", name: "apple", qty: 4, note: None })
INFO sqlx::query: summary="UPDATE \"item\" SET \"qty\" …" db.statement="\n\nUPDATE \"item\" SET \"qty\" = $1 WHERE \"item\".\"id\" = $2 RETURNING \"id\", \"sku\", \"name\", \"qty\", \"note\"\n" rows_affected=1 rows_returned=1 elapsed=648.709µs elapsed_secs=0.000648709
[sea-orm] Ok(Model { id: 1, sku: "a1", name: "apple", qty: 4, note: None })
=== 4. nothing Set, update (id 2)
DEBUG sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=297.417µs elapsed_secs=0.000297417
[mini] Ok(Model { id: 2, sku: "b2", name: "banana", qty: 5, note: None })
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=175µs elapsed_secs=0.000175
[sea-orm] Ok(Model { id: 2, sku: "b2", name: "banana", qty: 5, note: None })
=== 5. one column Set, id 99 does not exist
DEBUG sqlx::query: summary="UPDATE \"item\" SET \"qty\" …" db.statement="\n\nUPDATE \"item\" SET \"qty\" = $1 WHERE \"item\".\"id\" = $2 RETURNING \"id\", \"sku\", \"name\", \"qty\", \"note\"\n" rows_affected=0 rows_returned=0 elapsed=173.833µs elapsed_secs=0.000173833
[mini] Err(RecordNotUpdated)
INFO sqlx::query: summary="UPDATE \"item\" SET \"qty\" …" db.statement="\n\nUPDATE \"item\" SET \"qty\" = $1 WHERE \"item\".\"id\" = $2 RETURNING \"id\", \"sku\", \"name\", \"qty\", \"note\"\n" rows_affected=0 rows_returned=0 elapsed=157.5µs elapsed_secs=0.0001575
[sea-orm] Err(RecordNotUpdated)
=== 6. primary key NotSet, update
[mini] Err(PrimaryKeyNotSet)
[sea-orm] Err(PrimaryKeyNotSet { ctx: "UpdateOne" })
=== 7. insert, id and qty NotSet
DEBUG sqlx::query: summary="INSERT INTO \"item\" (\"sku\", …" db.statement="\n\nINSERT INTO \"item\" (\"sku\", \"name\") VALUES ($1, $2) RETURNING \"id\", \"sku\", \"name\", \"qty\", \"note\"\n" rows_affected=1 rows_returned=1 elapsed=596.166µs elapsed_secs=0.000596166
[mini] Ok(Model { id: 4, sku: "d4", name: "date", qty: 0, note: None })
INFO sqlx::query: summary="INSERT INTO \"item\" (\"sku\", …" db.statement="\n\nINSERT INTO \"item\" (\"sku\", \"name\") VALUES ($1, $2) RETURNING \"id\", \"sku\", \"name\", \"qty\", \"note\"\n" rows_affected=1 rows_returned=1 elapsed=575.125µs elapsed_secs=0.000575125
[sea-orm] Ok(Model { id: 4, sku: "d4", name: "date", qty: 0, note: None })
- 每一对
db.statement逐字相同,结果也相同。情况 4 两边都没有发UPDATE,只发了按主键的SELECT ... LIMIT $2;情况 6 两边都一条语句也没发,sea-orm 的错误多一个ctx字段。 - 情况 5 的
UPDATE匹配到 0 行,rows_returned=0,两边都变成RecordNotUpdated。
sqlx 日志里没有参数。要看参数的类型和字节,让两边的连接都经过一次 find 的全过程里的代理(复制到 cmp crate 的 src/proxy.rs,sslmode=disable),src/bin/wire.rs 跑一次上面的查询和一次 note 设成 Set(None) 的 update:
--- mini: filter + order_by + limit
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
[conn 1] C->S Parse(sqlx_s_1, "SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"qty\" > $1 AND \"item\".\"name\" LIKE $2 ORDER BY \"item\".\"qty\" DESC LIMIT $3"), Describe(S, sqlx_s_1), Sync
[conn 1] S->C ParseComplete, ParameterDescription(OID 23/25/20), RowDescription, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1, $1=0x00000003 $2=0x25616e25 $3=0x000000000000000a), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 4/2/6/4/-1), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
--- sea-orm: filter + order_by + limit
[conn 2] C->S Sync
[conn 2] S->C ReadyForQuery(I)
[conn 2] C->S Parse(sqlx_s_1, "SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"qty\" > $1 AND \"item\".\"name\" LIKE $2 ORDER BY \"item\".\"qty\" DESC LIMIT $3"), Describe(S, sqlx_s_1), Sync
[conn 2] S->C ParseComplete, ParameterDescription(OID 23/25/20), RowDescription, ReadyForQuery(I)
[conn 2] C->S Bind(portal unnamed, stmt sqlx_s_1, $1=0x00000003 $2=0x25616e25 $3=0x000000000000000a), Execute, Close(P, unnamed), Sync
[conn 2] S->C BindComplete, DataRow(列长 4/2/6/4/-1), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
[conn 2] C->S Sync
[conn 2] S->C ReadyForQuery(I)
--- mini: update, note Set to None
...
[conn 1] C->S Parse(sqlx_s_2, "UPDATE \"item\" SET \"note\" = $1 WHERE \"item\".\"id\" = $2 RETURNING \"id\", \"sku\", \"name\", \"qty\", \"note\""), Describe(S, sqlx_s_2), Sync
[conn 1] S->C ParseComplete, ParameterDescription(OID 25/23), RowDescription, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_2, $1=NULL $2=0x00000001), Execute, Close(P, unnamed), Sync
...
--- sea-orm: update, note Set to None
...
[conn 2] C->S Parse(sqlx_s_2, "UPDATE \"item\" SET \"note\" = $1 WHERE \"item\".\"id\" = $2 RETURNING \"id\", \"sku\", \"name\", \"qty\", \"note\""), Describe(S, sqlx_s_2), Sync
[conn 2] S->C ParseComplete, ParameterDescription(OID 25/23), RowDescription, ReadyForQuery(I)
[conn 2] C->S Bind(portal unnamed, stmt sqlx_s_2, $1=NULL $2=0x00000001), Execute, Close(P, unnamed), Sync
...
- 两条语句在线上逐字节相同:参数类型
int4/text/int8(OID 23/25/20),$2是%an%的四个字节,LIMIT是 8 字节的 10。 Set(None)两边都发成带text类型的 NULL(OID 25,$1=NULL)。- 前后孤立的
Sync/ReadyForQuery是 sqlx 连接池借出和归还时的 ping。两边底下都是 sqlx 的PgPool,test_before_acquire默认开着,所以都有。
LIMIT 做成参数,效果在同一条连接上换一个数字时看得出来。wire.rs 的最后让迷你版先 find().limit(2) 再 find().limit(3):
--- mini: find().limit(2), same connection
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
[conn 1] C->S Parse(sqlx_s_3, "SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" LIMIT $1"), Describe(S, sqlx_s_3), Sync
[conn 1] S->C ParseComplete, ParameterDescription(OID 20), RowDescription, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_3, $1=0x0000000000000002), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 4/2/6/4/-1), DataRow(列长 4/2/6/4/-1), CommandComplete("SELECT 2"), CloseComplete, ReadyForQuery(I)
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
=> 2 rows
--- mini: find().limit(3), same connection
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_3, $1=0x0000000000000003), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 4/2/6/4/-1), DataRow(列长 4/2/6/4/-1), DataRow(列长 4/2/5/4/-1), CommandComplete("SELECT 3"), CloseComplete, ReadyForQuery(I)
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
=> 3 rows
第二次没有 Parse,Bind 直接引用 sqlx_s_3。sqlx 的语句缓存以 SQL 文本为键(扩展查询协议与语句缓存),LIMIT 2 和 LIMIT 3 写成字面量就是两条不同的文本、两次 Parse;写成 $1 就是同一条。反过来,同一条文本换了参数的 Rust 类型会出事,见同一条 SQL,换了参数类型。
问: 既然参数化这么好,为什么表名、列名和 ORDER BY 的方向不也做成 $N,省得自己处理引号?
答: Postgres 的参数只能出现在「值」的位置。Parse 时服务端就要知道查哪张表、哪一列、按什么排序才能生成计划,标识符和关键字没法留到 Bind 再给。所以这一部分只能写进 SQL 文本,安全只能靠来源:迷你版的标识符都来自 &'static str 常量,再经 quote 加双引号、把内部的 " 写成两个;sea-query 的 QuotedBuilder::prepare_iden 做同样的事,只对运行期拼出来的(Cow::Owned)名字逐字符加倍,静态名字原样写。这也是 AssertSqlSafe 在 ORM 里能成立的原因:SQL 文本里不会出现用户给的值。
两处不同
除了空 INSERT 的写法,迷你版在行为上有两处和 sea-orm 2.0.4 不同。src/bin/diffs.rs:
println!("=== eq(None) on note (all three rows have note NULL)");
let r = mini::item::Entity::find()
.filter(mini::item::Column::Note.eq(None::<String>))
.all(&pool)
.await?;
println!("[mini] {} rows", r.len());
let r = item::Entity::find().filter(item::Column::Note.eq(None::<String>)).all(&db).await?;
println!("[sea-orm] {} rows", r.len());
println!("=== limit(u64::MAX)");
let r = mini::item::Entity::find().limit(u64::MAX).all(&pool).await;
println!("[mini] {r:?}");
let task = tokio::spawn(async move { item::Entity::find().limit(u64::MAX).all(&db).await.map(|v| v.len()) });
println!("[sea-orm] {:?}", task.await.map_err(|e| e.is_panic()));
第一段的结果放在页末的动脑筋答案里。第二段:
=== limit(u64::MAX)
[mini] Err(LimitTooLarge(18446744073709551615))
thread 'tokio-rt-worker' (3994088) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-query-sqlx-0.9.1/src/sqlx_postgres.rs:55:83:
called `Result::unwrap()` on an `Err` value: TryFromIntError(PosOverflow)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[sea-orm] Err(true)
sea-orm 的 limit 收 Into<Option<u64>>,sea-query 存成 Value::BigUnsigned,到 sea-query-sqlx 绑定成 Postgres 的 int8 时 unwrap,超过 i64::MAX 就 panic(Err(true) 是 JoinError::is_panic())。迷你版的 limit 同样收 u64,在 build 里转换失败就返回错误,语句不发出去。
note 都是 NULL。两边都写 Column::Note.eq(None::<String>),迷你版查到几行?sea-orm 查到几行?代码见上面的 diffs.rs,平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14,sea-orm 2.0.4。答案在页末。
没写的部分
| 没写 | 迷你版的表现 | sea-orm 里讲它的页 |
|---|---|---|
关系、join、find_related | 没有 RelationDef,条件也只能落在本表的列上 | 关系、join 与关联查询 |
| Loader 和 Entity Loader | 一对多只能自己再查一次 | Loader |
| 过程宏 | 每个实体手写约 90 行 | DeriveEntityModel 与 dense 格式 |
| 方言 | 只有 Postgres 的 $N、双引号和 RETURNING | QueryBuilder 与参数、设计取舍 |
| 复合主键 | PRIMARY_KEY 只能是一列 | 实体的几个 trait |
IS NULL、IN、OR、子查询 | 条件只有 =、>、<、LIKE,全部 AND | 列与值、语句树 |
save、delete、insert_many、on_conflict | 只有 insert 和 update | insert、save 与 ActiveModelBehavior |
ActiveModelBehavior 钩子 | 没有 | save 与 ActiveModelBehavior |
分页、部分列、into_tuple | all 总是取全部列、映射成 Model | 分页、部分列与自定义结果 |
DatabaseConnection、DbErr 的分类 | 直接收 PgExecutor,错误五个分支 | DatabaseConnection、错误 |
完整代码、关系和 Loader(把 RelationDef 加到这个迷你版上)、用声明宏或过程宏生成实体,以及练习题放在付费课程里。
动脑筋答案
diffs.rs 的第一段:
=== eq(None) on note (all three rows have note NULL)
DEBUG sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"note\" = $1\n" rows_affected=0 rows_returned=0 elapsed=3.856208ms elapsed_secs=0.003856208
[mini] 0 rows
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"sku\", \"item\".\"name\", \"item\".\"qty\", \"item\".\"note\" FROM \"item\" WHERE \"item\".\"note\" IS NULL\n" rows_affected=3 rows_returned=3 elapsed=3.011958ms elapsed_secs=0.003011958
[sea-orm] 3 rows
- 迷你版 0 行。
eq不看值,照样写"item"."note" = $1,$1绑成 NULL;SQL 里NULL = NULL的结果是 NULL 而不是真,WHERE一行都不留。 - sea-orm 2.0.4 三行。它在
eq里看到值是 NULL 就改写成IS NULL,SQL 里没有参数(列与值)。sea-orm 1.1.20 的行为和迷你版一样,发= $1绑 NULL,这在 2.0 里才改。
小结
- 一个 ORM 的骨架不大:实体 trait 描述表,构造器把条件拼成
$N和参数,ActiveValue三态决定写哪几列,最后都是一次sqlx::query_with(AssertSqlSafe(sql), args)。迷你版四个文件约 500 行,只依赖 sqlx 和 tokio。 - 值只从
push_param一个口子进 SQL,LIMIT也是;SQL 文本里只剩加了引号的标识符,所以AssertSqlSafe成立,同一形状的查询换参数还能命中 sqlx 的语句缓存。 update的三条规矩照 sea-orm 2.0:主键NotSet返回错误不发语句;只SET被Set的非主键列;没有列要写时改为按主键SELECT。insert写Set和Unchanged,RETURNING拿回整行。- 七种操作发出的 SQL 和 sea-orm 2.0.4 逐字相同,抓包的两条语句连参数类型和字节都一样。
- 有意不同的两处:
limit超出int8时迷你版返回错误,sea-orm 在绑定时 panic;eq(None)迷你版发= NULL、查不到行,sea-orm 2.0 改写成IS NULL。条件按列类型区分实体,filter拿错实体的列编译不过,代价是不能跨表过滤。