Rust in Everything
基于 sea-orm 2.0.4

sea-orm 解决什么问题

主线位置:三条主线的由来。本页说明 sea-orm 为什么有定义主线(实体是宏生成的类型)、查询主线为什么在运行期才写出 SQL、写入主线为什么围绕 ActiveModel,以及和 sqlx、diesel 相比,它把哪些检查放在编译期、哪些留到了运行期。

flowchart TB
  need["Rust 里访问 Postgres"] --> sql["自己写 SQL"]
  need --> dsl["用 Rust 表达查询"]
  sql --> sqlx["sqlx 编译期连库检查"]
  dsl --> diesel["diesel 查询是类型"]
  dsl --> orm["sea-orm 查询是值"]
  orm -->|"语句树"| sq["sea-query"]
  orm -->|"执行"| sqlx

sea-orm 怎么描述自己

Cargo.toml 的 description 是「🐚 An async & dynamic ORM for Rust」。src/docs.rs 列了四条原则:Async、Dynamic、Testable、Service Oriented。前两条的原话是:

1. Async
    Relying on SQLx, SeaORM is a new library with async support from day 1.
2. Dynamic
    Built upon SeaQuery, SeaORM allows you to build complex queries without 'fighting the ORM'.

(去掉了原文的链接。)README 的「Familiar Concepts」一节说它「Inspired by popular ORMs in the Ruby, Python, and Node.js ecosystem」。sea-query 的 Cargo.toml 描述是「🔱 A dynamic query builder for MySQL, Postgres and SQLite」。

两个库都用了 dynamic 这个词,指的是同一件事:查询是运行期的值。cake::Entity::find() 返回一个 Select<cake::Entity>,里面是一棵 sea-query 的语句树;.filter(..)、.order_by(..)、.limit(..) 往树上加节点,可以放在 if 里、循环里、函数之间传来传去;直到执行时才写成 SQL。这三件事分别落在三条主线上:

原则落在哪里讲它的部分
Async:执行建立在 sqlx 上查询主线的后半段,每条语句 pool.acquire() 再 fetch_*一次 find 的全过程、06 连接与事务
Dynamic:查询建立在 sea-query 上查询主线的前半段,Select 是语句树,build 时才选方言03 sea-query、04 查询
实体由宏生成定义主线,DeriveEntityModel / #[sea_orm::model]02 实体
Testable:mock 连接MockDatabase 记下每条语句Mock 与 Proxy

查询是值:一个搜索表单

一个典型的场景:搜索表单的每个字段都可以不填,填了才加条件。

// a search form: every field is optional, the query is assembled at runtime
fn search(name_like: Option<&str>, min_id: Option<i32>, newest_first: bool) -> sea_orm::Select<cake::Entity> {
  let cond = Condition::all()
    .add_option(name_like.map(|s| cake::Column::Name.contains(s)))
    .add_option(min_id.map(|id| cake::Column::Id.gte(id)));
  let q = cake::Entity::find().filter(cond);
  if newest_first { q.order_by_desc(cake::Column::Id) } else { q }
}

对三组输入各 build(DbBackend::Postgres) 一次,打印 Statement 的 sql 和 values(M2 Max、macOS、rustc 1.99.0):

None None false =>
  SELECT "cake"."id", "cake"."name" FROM "cake" WHERE TRUE
  Some(Values([]))
Some("cheese") None false =>
  SELECT "cake"."id", "cake"."name" FROM "cake" WHERE "cake"."name" LIKE $1
  Some(Values([String(Some("%cheese%"))]))
Some("cheese") Some(10) true =>
  SELECT "cake"."id", "cake"."name" FROM "cake" WHERE "cake"."name" LIKE $1 AND "cake"."id" >= $2 ORDER BY "cake"."id" DESC
  Some(Values([String(Some("%cheese%")), Int(Some(10))]))

同一个函数,三条不同的 SQL;用户输入的 cheese 始终在 values 里,SQL 里只有 $1。add_option(None) 什么也不加,一个条件都没有的 Condition::all() 写成了 WHERE TRUE。Condition 和语句树的结构见语句是一棵树。

用 sqlx 写同样的东西,要么准备好几条完整的 SQL,要么用 sqlx 的 QueryBuilder 拼字符串、自己管 push_bind 和 AND 的位置;而 query! 只能检查写在宏里的一条固定的 SQL(见 sqlx 卷的 query! 的流水线)。sea-orm 把「拼」变成了对一棵树的方法调用,代价是这棵树在编译期只按 Rust 的类型检查,不按数据库检查。

📝NOTE
没有蠢问题

问: SQL 在运行期拼出来,会不会有注入的问题?

答: 值不会进 SQL。Column::Name.eq(..)、contains(..) 收的值都变成 Value,build 时写成 $N,值放进 values,执行时作为参数绑定;标识符来自实体的 Column 枚举,是宏生成的字符串常量,并且总是加双引号。真正会把字符串原样放进 SQL 的是 sea-query 的 Expr::cust,它就是「这段 SQL 我自己负责」的出口。同一个恶意输入 x' OR '1'='1,两种写法 build 出来是这样:

eq   : SELECT "cake"."id", "cake"."name" FROM "cake" WHERE "cake"."name" = $1
       Some(Values([String(Some("x' OR '1'='1"))]))
cust : SELECT "cake"."id", "cake"."name" FROM "cake" WHERE name = 'x' OR '1'='1'
       Some(Values([]))

第二种是 filter(Expr::cust(format!("name = '{evil}'")))。sea-orm 在驱动里对整条 SQL 用 AssertSqlSafe(见一次 find 的全过程),这个承诺对 eq 成立,对拼进 Expr::cust 的用户输入不成立。哪些值变成参数、哪些直接写进 SQL,见 QueryBuilder 与参数。

和 sqlx 比:检查对着谁

sqlx 的定位是写原生 SQL,query! 在编译期连上开发数据库,让数据库自己检查 SQL,拿回参数和列的类型(见 sqlx 卷的 sqlx 解决什么问题)。检查对着的是数据库里真实的 schema。

sea-orm 的检查对着的是Rust 里的实体。实体是一个 Model struct,宏从它生成 Column 枚举、PrimaryKey 枚举和一串 trait 实现;查询里引用列,写的是 cake::Column::Name 这样的枚举值。于是 rustc 能检查的是「这个列在实体里存在」:

let _ = cake::Entity::find().filter(cake::Column::Nmae.eq("cheese"));
error[E0599]: no variant, associated function, or constant named `Nmae` found for enum `exp_seaorm_intro::cake::Column` in the current scope
 --> src/bin/typo.rs:5:53
  |
5 |   let _ = cake::Entity::find().filter(cake::Column::Nmae.eq("cheese"));
  |                                                     ^^^^ variant, associated function, or constant not found in `exp_seaorm_intro::cake::Column`
  |
help: there is a variant with a similar name
  |
5 -   let _ = cake::Entity::find().filter(cake::Column::Nmae.eq("cheese"));
5 +   let _ = cake::Entity::find().filter(cake::Column::Name.eq("cheese"));
  |

它不能检查的是「实体和数据库对得上」。给同一张 cake 表定义一个多了 price: i32 字段的实体,编译完全正常,运行时 find_by_id(1).one(&db) 才失败:

drift: Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "42703", message: "column cake.price does not exist", detail: None, hint: None, position: Some(Original(36)), where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("parse_relation.c"), line: Some(3722), routine: Some("errorMissingColumn") }))))
Display: Query Error: error returned from database: column cake.price does not exist at line 3722

(at line 3722 是服务端 C 源码 parse_relation.c 的行号,不是 SQL 的行号,见 sqlx 卷的错误。)

值的类型也是一样。Column 枚举上的 eq 收任何 Into<Value>,给整数列传一个字符串,编译能过,错误来自 Postgres:

Column::Id.eq("abc") => Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "42883", message: "operator does not exist: integer = text", detail: None, hint: Some("No operator matches the given name and argument types. You might need to add explicit type casts."), position: Some(Original(65)), where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("parse_oper.c"), line: Some(647), routine: Some("op_error") }))))

2.0 的 #[sea_orm::model] 另外生成了强类型列 cake::COLUMN,COLUMN.id 是一个 NumericColumn,它的 eq 要求值实现 NumericValue,同样的错误在编译期就报出来:

error[E0277]: the trait bound `&str: NumericValue` is not satisfied
   --> src/bin/wrong_type.rs:34:58
    |
 34 |   let _ = cake::Entity::find().filter(cake::COLUMN.id.eq("abc"));
    |                                                       -- ^^^^^ the trait `NumericValue` is not implemented for `&str`
    |                                                       |
    |                                                       required by a bound introduced by this call
    |
    = help: the following other types implement trait `NumericValue`:
              f32
              f64
              i16
              i32
              i64
              i8
              sea_orm::prelude::Decimal
              u16
            and 3 others

它检查的是「数值类」而不是「i32」:f64 也在列表里。强类型列的细节见列与值。

sqlx query!sea-orm
查询写成什么SQL 字符串方法调用构造的语句树
SQL 什么时候确定写代码时运行期,build 时
编译期检查对着谁开发数据库里的 schemaRust 里的实体
列名写错编译期,数据库报错编译期,rustc 报 E0599
实体和数据库不一致不存在这个问题(没有实体)运行期,数据库报错
值的类型不对编译期Column::X 运行期;2.0 的 COLUMN.x 编译期按类别检查
编译要不要连库要,或者用 .sqlx 缓存不要

两者不互斥:sea-orm 的每条语句最后就是一个 sqlx::query_with,复杂的查询可以绕过实体用 raw_sql! 或直接拿 get_postgres_connection_pool() 用 sqlx。

和 diesel 比:查询是值还是类型

本卷没有读 diesel 的源码(它不在本机的 registry 里),只说它公开的设计:表结构用 table! 宏写在 Rust 里(通常由 diesel print-schema 从数据库生成),每张表、每一列都是一个类型,列的 SQL 类型也写在类型里;查询是这些类型组合出来的表达式,rustc 检查整条查询的类型。diesel 本身的连接 API 是同步的,异步要另用 diesel-async。

和 sea-orm 比,分界线是「列是类型还是值」:

  • diesel 的列是类型,查询的形状也编码在类型里。好处是更多的错误在编译期出现(列和值的类型、join 了哪些表);代价是动态拼查询要和类型系统打交道,比如按条件加过滤时,各分支的类型要统一成一个装箱的类型。
  • sea-orm 的列是枚举值(cake::Column::Name),实体是单元结构体(cake::Entity),查询是 Select<E> 里的一棵运行期的树,E 只决定从哪张表开始、结果解码成什么。按条件加过滤就是普通的 if,Select<E> 的类型不变。代价就是上一节那几行运行期错误。docs.rs 说的「without 'fighting the ORM'」指的就是这一点。

两者共同的地方是 schema 都在 Rust 里,都要和数据库保持一致:diesel 靠 print-schema 生成,sea-orm 靠 sea-orm-cli generate entity 生成实体,或者反过来,2.0 的 schema sync 从实体出发去改数据库(见 Entity First:schema sync)。

写入:Model 和 ActiveModel

读出来的是 cake::Model,一个普通的 struct:字段就是值,不带连接,也不记得自己从哪来。要写,就换成 cake::ActiveModel,它的每个字段是一个 ActiveValue:Set(要写)、Unchanged(读来的、没改)、NotSet(不写)。

let am = cake::ActiveModel { name: Set("cheese cake".to_owned()), ..Default::default() };
let model: cake::Model = am.insert(&db).await?;

insert、update、save、delete 是 ActiveModelTrait 上的方法,看起来像 Rails 那种 ActiveRecord:对象自己会保存自己。但连接每次都要显式传进去(&db 或 &txn),对象里不存连接;Model 上(ModelTrait)只有 delete、find_related 等少数几个方法,insert / update / save 都要先换成 ActiveModel。三态的意义在于 update 只写被 Set 的列,NotSet 的列在 insert 时交给数据库的默认值。写入主线的细节见 05 写入;为什么是三态而不是「改过的字段集合」,在设计取舍里汇总。

sea-query 为什么单独成一个库

sea-query 的 README 说「SeaQuery is a query builder to help you construct dynamic SQL queries in Rust. You can construct expressions, queries and schema as abstract syntax trees using an ergonomic API.」,又说「SeaQuery is the foundation of SeaORM」。拆开有几个可以从依赖关系上看到的结果(见源码地图):

  • sea-query 不依赖任何驱动。语句树和方言(QueryBuilder)在一个库里,和驱动的绑定(sea-query-sqlx、sea-query-rusqlite 等)各是一个小库。同一棵树可以交给 sqlx 执行,也可以交给 rusqlite,sea-orm-sync 就是这样换掉驱动的。
  • 不连库就能看到 SQL。build(DbBackend::Postgres) 只需要 sea-query,可以在单元测试里断言「生成了什么 SQL」,MockDatabase 也建立在这一点上。
  • 不只 sea-orm 用它。sea-orm-codegen 依赖 sea-query 而不依赖 sea-orm;迁移里的 create_table、alter_table 用的是 sea-query 的 TableCreateStatement 这一套(见 SchemaManager 与从实体建表);不想要 ORM 的项目可以直接用 sea-query 加 sqlx。

代价是多一层:sea-orm 里的每个操作先翻译成一棵 sea-query 的树,再由 QueryBuilder 写成字符串;能表达的是三种方言的公共部分加上各自的扩展。

同一个实体,两种写法

本站用的 1.1.20 是「展开」写法:Model 加 DeriveEntityModel,关系写成一个手写的 Relation 枚举(DeriveRelation),多表之间再手写 impl Related<..>。2.0 加了 dense 写法:在 Model 上多加一个 #[sea_orm::model],关系写成 Model 里的 HasOne / HasMany / BelongsTo 字段,Relation、Related 由宏生成,另外多出 ModelEx、ActiveModelEx、EntityLoader、强类型列 COLUMN。没有关系的一张表,两种写法是这样的:

// 2.0 dense
pub mod cake {
  use sea_orm::entity::prelude::*;

  #[sea_orm::model]
  #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
  #[sea_orm(table_name = "cake")]
  pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
  }

  impl ActiveModelBehavior for ActiveModel {}
}

// 1.x expanded, still accepted by 2.0.4
mod cake_expanded {
  use sea_orm::entity::prelude::*;

  #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
  #[sea_orm(table_name = "cake")]
  pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
  }

  #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
  pub enum Relation {}

  impl ActiveModelBehavior for ActiveModel {}
}

两者在 2.0.4 上都能编译,对同一张表 find_by_id(1).one(&db) 拿回同一行(expanded find_by_id(1) => Ok(Some(Model { id: 1, name: "cheese cake" })))。

本卷以 dense 写法为主:它是 2.0 的新东西,Entity Loader 和 ActiveModelEx 只在它上面有;展开写法对照着讲,因为本站的 crates/core/src/entities/ 用的是它,而且宏展开之后,两种写法生成的 Entity、Column、RelationDef 是同一套东西(逐项对比见 DeriveEntityModel 与 dense 格式和关系)。

小结

  • sea-orm 的定位是「async & dynamic」:执行建立在 sqlx 上,查询是运行期的值,由 sea-query 的语句树表示,build 时才写成 SQL。
  • 动态拼查询就是普通的 if 和方法调用;值始终是参数,只有 Expr::cust 会把字符串原样放进 SQL。
  • 和 sqlx 比:sqlx 的检查对着数据库,sea-orm 的检查对着 Rust 里的实体;列名写错编译不过,实体和数据库不一致、Column::X.eq 的值类型不对要到运行期,2.0 的 COLUMN.x 在编译期按类别检查值。
  • 和 diesel 比:diesel 的列和查询是类型,sea-orm 的列是枚举值、查询是树,所以拼起来容易、编译期抓到的少。
  • Model 是纯数据,ActiveModel 每个字段带三态,写入方法每次显式收连接。
  • sea-query 单独成库:不依赖驱动、不连库就能出 SQL、迁移和代码生成也用它。
  • 本卷以 2.0 的 dense 写法为主,对照本站用的展开写法。

💬 关联讨论

发起讨论
加载中...