Rust in Everything
基于 sea-orm 2.0.4

Mock 与 Proxy

主线位置:查询主线和写入主线在「DatabaseConnection 按 inner 分派到驱动」那一步换了终点。语句照样由 sea-query 生成、照样变成一个 Statement,只是不再交给 sqlx_query 和连接池,而是交给 MockDatabase(按顺序取预置结果)或者你自己实现的 ProxyDatabaseTrait。它们之前的一切(Select、build、$N 和 Values)和真实连接完全相同,之后的一切(执行、类型、锁、事务语义)全不相同。

flowchart TB
  stmt["Statement 和 Values"] --> inner{"DatabaseConnectionType"}
  inner -->|"SqlxPostgres"| sqlx["sqlx_query 和连接池"]
  inner -->|"MockDatabaseConnection"| mockc["两个计数器"]
  inner -->|"ProxyDatabaseConnection"| proxyc["ProxyDatabaseTrait"]
  mockc --> queue["按序号取预置结果"]
  mockc --> log["记进 transaction_log"]
  proxyc --> user["你写的 query 和 execute"]
  queue --> qr["QueryResult 或 ExecResult"]
  user --> qr
  sqlx --> qr

实验 crate 依赖 sea-orm =2.0.4(sqlx-postgres、runtime-tokio-rustls、macros、with-json),mock、proxy 是 crate 自己的 feature(mock = ["sea-orm/mock"]、proxy = ["sea-orm/proxy"]),按实验分别打开;对照用的 1.1.20 是另一个 crate。平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。实体和 事务 一页相同:item,两列 id、n。

mock feature 改了什么

mock = [] 不带任何依赖,只打开一批 #[cfg(feature = "mock")]。最显眼的是 DatabaseConnectionType 多一个分支:

    /// In-memory mock connection used for testing (`mock`).
    #[cfg(feature = "mock")]
    MockDatabaseConnection(Arc<crate::MockDatabaseConnection>),

跟着它,事务里的 InnerConnection、执行结果 ExecResultHolder、行 QueryResultRow 也各多一个 Mock 分支,ConnectionTrait 的每个方法里多一个 match 臂;Database::connect 在所有真实驱动都不认这个 URL 时,交给 MockDatabaseConnector(开着 sqlx-postgres 时 postgres:// 先被 sqlx 驱动接走,走不到这里)。

DatabaseConnectionType 没有 #[non_exhaustive],所以多一个分支是会被下游看见的:

use sea_orm::{DatabaseConnection, DatabaseConnectionType};

fn need_clone<T: Clone>() {}

fn main() {
  need_clone::<DatabaseConnection>();
  let db = DatabaseConnection::default();
  match db.inner {
    DatabaseConnectionType::SqlxPostgresPoolConnection(_) => println!("postgres"),
    DatabaseConnectionType::Disconnected => println!("disconnected"),
  }
}
== 2.0.4 no mock
disconnected
== 2.0.4 mock
error[E0004]: non-exhaustive patterns: `DatabaseConnectionType::MockDatabaseConnection(_)` not covered
  --> src/bin/mock_shape.rs:8:9
   |
 8 |   match db.inner {
   |         ^^^^^^^^ pattern `DatabaseConnectionType::MockDatabaseConnection(_)` not covered
   |
note: `DatabaseConnectionType` defined here
  --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-2.0.4/src/database/db_connection.rs:47:1
   |
47 | pub enum DatabaseConnectionType {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
66 |     MockDatabaseConnection(Arc<crate::MockDatabaseConnection>),
   |     ---------------------- not covered
   = note: the matched value is of type `DatabaseConnectionType`
...

need_clone::<DatabaseConnection>() 两次都过了:2.0.4 的 DatabaseConnection 和 DatabaseConnectionType 无条件 #[derive(Clone)]。

本站 payment crate 的注释

本站 crates/modules/payment/Cargo.toml 的 [dev-dependencies] 上面写着:

# 注意:不用 sea-orm 的 mock feature——它会经 feature 统一全局禁用
# `DatabaseConnection: Clone`,破坏 app-core 连接池。

本站用的是 1.1.20。1.1.20 的 DatabaseConnection 还是 enum,Clone 是条件派生的:

/// Handle a database connection depending on the backend enabled by the feature
/// flags. This creates a database pool. This will be `Clone` unless the feature
/// flag `mock` is enabled.
#[cfg_attr(not(feature = "mock"), derive(Clone))]
pub enum DatabaseConnection {

同一个 need_clone 在 1.1.20 上:

== 1.1.20 no mock
DatabaseConnection: Clone
== 1.1.20 mock
error[E0277]: the trait bound `DatabaseConnection: Clone` is not satisfied
 --> src/main.rs:6:16
  |
6 |   need_clone::<DatabaseConnection>();
  |                ^^^^^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `DatabaseConnection`
  |
note: required by a bound in `need_clone`
 --> src/main.rs:3:18
  |
3 | fn need_clone<T: Clone>() {}
  |                  ^^^^^ required by this bound in `need_clone`

「经 feature 统一」要再精确一点。用一个两成员的 workspace 模拟(resolver = "2",和本站一样):core-x 依赖 sea-orm =1.1.20,有一个 db.clone() 返回 DatabaseConnection 的函数,像本站 crates/core/src/db/pool.rs 那样;pay-x 依赖 core-x,[dev-dependencies] 里给 sea-orm 加 features = ["mock"]:

== cargo build -p pay-x
exit 0
== cargo test -p pay-x
error[E0308]: mismatched types
 --> core/src/lib.rs:5:3
  |
4 | pub fn pool_clone(db: &DatabaseConnection) -> DatabaseConnection {
  |                                               ------------------ expected `DatabaseConnection` because of return type
5 |   db.clone()
  |   ^^^^^^^^^^ expected `DatabaseConnection`, found `&DatabaseConnection`
  |
note: `DatabaseConnection` does not implement `Clone`, so `&DatabaseConnection` was cloned instead
 --> core/src/lib.rs:5:3
  |
5 |   db.clone()
  |   ^^
...
error: could not compile `core-x` (lib) due to 1 previous error
== cargo test -p core-x (1.1.20)
...
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

resolver 2 下,dev-dependency 的 feature 只在「这次构建要编译 dev-dependency」时才和普通依赖统一。所以 cargo build 不受影响;cargo test -p pay-x(或者对整个 workspace 跑 cargo test)时,整个构建图里只有一份 sea-orm,mock 打开了,core-x 里的 db.clone() 编译失败;单独测 core-x 没事。注释在 1.1.20 下成立,范围是「编译 payment 的测试时」,不是任何构建都「全局」。同样的 workspace 把版本换成 =2.0.4,cargo test -p pay-x 通过。升级到 2.0 之后,这条理由就不存在了,剩下的影响只是上面那个 match 的穷尽性。

📝NOTE
冷知识:mock 曾经让连接不能 Clone

#438「Make DatabaseConnection Clone with the default features enabled」说:「Currently the mock feature is by default enabled and that causes the sea_orm::DatabaseConnection to not implement Clone by default, because MockDatabaseConnection (which is a variant of DatabaseConnection) is not clonable」,像 actix-web 这样要求全局数据可克隆的框架因此只能关掉 mock。#562「Exclude mock from default features」把 mock 移出了默认 feature(0.6.0 的 default 里还有它,0.7.0 起没有),条件派生留着。随后的 #665「DatabaseConnection always derive Clone」没有合并,讨论里的意见是「I am more inclined to keep the semantics of a non cloneable MockConnection. It leads to weird behaviour anyway.」以及「the canonical way is to enable mock only as dev dependency」。本站正是这么做的,结果撞上了 dev-dependency 的 feature 统一。条件派生一直留到 #2683「Role Based Access Control」:其中一个提交把两处 #[cfg_attr(not(feature = "mock"), derive(Clone))] 换成了无条件的 #[derive(Clone)](PR 说明没有提这一点),git tag --contains 给出的第一个版本是 2.0.0-rc.28。mock 分支装的是 Arc<MockDatabaseConnection>,克隆的只是 Arc,几个克隆共用同一份结果队列和日志。

MockDatabase:两个队列、两个计数器、一份日志

MockDatabase:

pub struct MockDatabase {
    db_backend: DbBackend,
    transaction: Option<OpenTransaction>,
    transaction_log: Vec<Transaction>,
    exec_results: Vec<Result<MockExecResult, DbErr>>,
    query_results: Vec<Result<Vec<MockRow>, DbErr>>,
}

append_query_results 收「结果集的列表」,每个结果集是一串能 IntoMockRow 的东西(Model、(Model, Model)、BTreeMap<String, Value>);append_exec_results 收 MockExecResult { last_insert_id, rows_affected };还有对应的 append_*_errors。into_connection() 把它装进 MockDatabaseConnection:

pub struct MockDatabaseConnection {
    execute_counter: AtomicUsize,
    query_counter: AtomicUsize,
    mocker: Mutex<Box<dyn MockDatabaseTrait>>,
}

execute 和 query_one / query_all 各有一个计数器,每次调用 fetch_add(1) 拿一个序号,交给 MockDatabase::query:

    fn query(&mut self, counter: usize, statement: Statement) -> Result<Vec<QueryResult>, DbErr> {
        if let Some(transaction) = &mut self.transaction {
            transaction.push(statement);
        } else {
            self.transaction_log.push(Transaction::one(statement));
        }
        if counter < self.query_results.len() {
            match std::mem::replace(
                &mut self.query_results[counter],
                Err(query_err("this value has been consumed already")),
            ) {
                // ...
            }
        } else {
            Err(query_err("`query_results` buffer is empty."))
        }
    }

先记日志,再按序号取结果:第 N 次查询拿第 N 个结果集,和 SQL 写了什么无关。mocker 是 Box<dyn MockDatabaseTrait>,MockDatabaseConnection::new 是公开的,想按 SQL 匹配结果可以自己实现 MockDatabaseTrait。

// P
let db = MockDatabase::new(DatabaseBackend::Postgres)
  .append_query_results([
    vec![item::Model { id: 1, n: 10 }],
    vec![item::Model { id: 7, n: 70 }, item::Model { id: 8, n: 80 }],
  ])
  .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 3 }])
  .into_connection();
let m = item::ActiveModel { n: Set(10), ..Default::default() }.insert(&db).await?;
let v = item::Entity::find().filter(item::Column::N.gt(1000)).all(&db).await?;
let r = db
  .execute_raw(Statement::from_string(DatabaseBackend::Postgres, "DELETE FROM no_such_table"))
  .await?;
let e = item::Entity::find_by_id(1).one(&db).await;
show(db.into_transaction_log()); // 每个 Transaction 一行一条语句,后面是 Values
--- P: 预置结果,按顺序取 ---
insert 返回 Model { id: 1, n: 10 }
find().filter(n > 1000).all 返回 [Model { id: 7, n: 70 }, Model { id: 8, n: 80 }]
DELETE FROM no_such_table 返回 rows_affected = 3
第三次查询 = Err(Query(Internal("`query_results` buffer is empty.")))
  Transaction 0:
    INSERT INTO "item" ("n") VALUES ($1) RETURNING "id", "n"  [Int(Some(10))]
  Transaction 1:
    SELECT "item"."id", "item"."n" FROM "item" WHERE "item"."n" > $1  [Int(Some(1000))]
  Transaction 2:
    DELETE FROM no_such_table
  Transaction 3:
    SELECT "item"."id", "item"."n" FROM "item" WHERE "item"."id" = $1 LIMIT $2  [Int(Some(1)), BigUnsigned(Some(1))]
  • Postgres 上的 insert 带 RETURNING,走的是查询,消耗的是 query_results 的第 0 个,不是 exec_results。哪个操作走哪个队列取决于后端和语句,预置结果时要知道这一点。
  • n > 1000 的查询拿回了 n 是 70、80 的行,DELETE FROM no_such_table 返回 3 行受影响:SQL 根本没被看过。
  • 失败的那次查询也进了日志。日志里的 Statement 和发给 sqlx 的是同一个值:$N 占位符、Values 原样,LIMIT 是 BigUnsigned 参数。测试里通常拿它和 Transaction::from_sql_and_values(DbBackend::Postgres, sql, [values]) 做 assert_eq!,into_transaction_log 把日志取走(drain),连接不是 mock 时 panic。

事务在 mock 里

db.begin() 在 mock 分支走 new_mock,同样是 DatabaseTransaction::begin,InnerConnection::Mock 分支调 MockDatabase 的 begin。事务状态是 MockDatabase 里的一个 Option<OpenTransaction>,深度记在它身上,SQL 文本是 mock 自己写的(OpenTransaction):

    fn init() -> Self {
        Self {
            stmts: vec![Statement::from_string(DbBackend::Postgres, "BEGIN")],
            transaction_depth: 0,
        }
    }

    fn begin_nested(&mut self, db_backend: DbBackend) {
        self.transaction_depth += 1;
        self.push(Statement::from_string(
            db_backend,
            format!("SAVEPOINT savepoint_{}", self.transaction_depth),
        ));
    }

commit / rollback 在深度 0 时写 COMMIT / ROLLBACK 并把整个 OpenTransaction 收进日志,否则写 RELEASE SAVEPOINT savepoint_N / ROLLBACK TO SAVEPOINT savepoint_N。DatabaseTransaction 的 start_rollback 在 mock 分支直接调 c.rollback():没有写缓冲可排,Drop 当场记一条 ROLLBACK。

❗IMPORTANT
动脑筋:&db 上的语句记在哪里?

M2 Max、macOS、rustc 1.99.0,mock feature 打开:

// Q
let db = MockDatabase::new(DatabaseBackend::Postgres)
  .append_exec_results(vec![MockExecResult { last_insert_id: 0, rows_affected: 1 }; 4])
  .into_connection();
let up = |n: i32| {
  item::Entity::update_many()
    .col_expr(item::Column::N, sea_orm::sea_query::Expr::value(n))
    .filter(item::Column::Id.eq(1))
};
let txn = db
  .begin_with_config(Some(IsolationLevel::Serializable), Some(AccessMode::ReadOnly))
  .await?;
up(1).exec(&txn).await?;
let sp = txn.begin().await?;
up(2).exec(&sp).await?;
sp.rollback().await?;
up(3).exec(&db).await?; // &db, not &txn
txn.commit().await?;
let txn = db.begin().await?;
up(4).exec(&txn).await?;
drop(txn);
show(db.into_transaction_log());

日志里有几个 Transaction?up(3)(用的是 &db)是单独一个 Transaction,还是夹在第一个事务的 ROLLBACK TO SAVEPOINT 和 COMMIT 之间?有没有 SET TRANSACTION?

它测不了什么

MockDatabase 证明的是「sea-orm 把这个调用翻译成了这些 Statement,按这个顺序」。凡是要数据库回答的问题,它都答不了。上面的实验和下面这一个,每一条都对应一种测不出来的错误:

问题真实 PostgresMockDatabase
SQL 能不能跑(表名、列名、语法、类型)报错不看 SQL,DELETE FROM no_such_table 返回预置的 rows_affected
WHERE / LIMIT 对不对决定返回哪些行原样返回预置的行
约束(非空、唯一、外键)报错,sql_err() 能认出来除非 append_*_errors 预置一个错误
列的类型和解码sqlx 按 OID 检查,int4 读成 i64 报 mismatched types按 Value 分支转换,转换失败一律报成 NULL(见下)
隔离级别、只读SET TRANSACTION ...new_mock 不传配置,日志里没有这条语句
&db 和 &txn 是两条连接看不到未提交的写入、可能等锁或等池只有一个 transaction 槽,&db 的语句被记进开着的事务(动脑筋答案)
保存点的名字_sqlx_savepoint_Nsavepoint_N
Drop 回滚的时机排进写缓冲,归还或下次使用时发出当场记一条 ROLLBACK
连接池、超时、取消见事务没有池

类型那一行:mock 的行是 BTreeMap<String, Value>,TryGetable 在 QueryResultRow::Mock 那一臂把 MockRow::try_get 的任何错误都换成「遇到了 NULL」:

                    #[cfg(feature = "mock")]
                    QueryResultRow::Mock(row) => row.try_get(idx).map_err(|e| {
                        debug_print!("{:#?}", e.to_string());
                        err_null_idx_col(idx)
                    }),

count() 读的是 num_items 列、i64。给它预置三种值:

// X
let db = MockDatabase::new(DatabaseBackend::Postgres)
  .append_query_results([[BTreeMap::from([("num_items", Value::BigInt(Some(3)))])]])
  .append_query_results([[BTreeMap::from([("num_items", Value::Int(Some(3)))])]])
  .append_query_results([[BTreeMap::from([("num_items", Value::String(Some("3".into())))])]])
  .into_connection();
for label in ["BigInt", "Int", "String"] {
  println!("{label}: {:?}", item::Entity::find().count(&db).await);
}
--- X: count() 读 num_items ---
BigInt: Ok(3)
Int: Err(Type("A null value was encountered while decoding \"num_items\""))
String: Err(Type("A null value was encountered while decoding \"num_items\""))
SELECT COUNT(*) AS num_items FROM (SELECT "item"."id", "item"."n" FROM "item") AS "sub_query"

值明明不是 NULL,报的却是 NULL。用 mock 时看到这条错误,先查预置的 Value 分支和字段类型对不对得上;真实的类型错误长什么样,见 列与值。

📝NOTE
没有蠢问题

问: 既然测不了 SQL 能不能跑,MockDatabase 还有什么用?

答: 它测的是你的代码,不是数据库:一个 handler 在某种输入下发了几条语句、按什么顺序、带什么参数,update 有没有只 SET 改过的列,出错的分支有没有走到 rollback,某个 DbErr 有没有被正确地翻译成业务错误(用 append_query_errors 注入)。这些断言不需要数据库,跑得快,结果确定。要证明 SQL 在 Postgres 上能跑、结果对,得连一个真的库,例如 sqlx 卷的 #[sqlx::test] 那样每个测试一个临时库。

ProxyDatabase:把语句交给你

proxy feature(proxy = ["serde/derive"])打开另一个分支 ProxyDatabaseConnection。连接由 Database::connect_proxy 造,参数是后端(决定 sea-query 用哪种方言)和一个 Arc<Box<dyn ProxyDatabaseTrait>>:

#[async_trait::async_trait]
pub trait ProxyDatabaseTrait: Send + Sync + Debug {
    /// Execute a query in the [ProxyDatabase], and return the query results
    async fn query(&self, statement: Statement) -> Result<Vec<ProxyRow>, DbErr>;

    /// Execute a command in the [ProxyDatabase], and report the number of rows affected
    async fn execute(&self, statement: Statement) -> Result<ProxyExecResult, DbErr>;

    /// Begin a transaction in the [ProxyDatabase]
    async fn begin(&self) {}

    /// Commit a transaction in the [ProxyDatabase]
    async fn commit(&self) {}

    /// Rollback a transaction in the [ProxyDatabase]
    async fn rollback(&self) {}

    /// Start rollback a transaction in the [ProxyDatabase]
    fn start_rollback(&self) {}

    /// Ping the [ProxyDatabase], it should return an error if the database is not available
    async fn ping(&self) -> Result<(), DbErr> {
        Ok(())
    }
}

ProxyDatabaseTrait 只有 query 和 execute 必须实现。你拿到的是完整的 Statement(SQL 加 Values),怎么执行、发到哪里由你决定;返回的 ProxyRow 和 MockRow 一样是 BTreeMap<String, Value>,解码也走上面那条「错误一律报成 NULL」的路。仓库里的两个例子 proxy_gluesql_example、proxy_cloudflare_worker_example 用它把 sea-orm 接到 GlueSQL 和 Cloudflare Worker 上:sea-orm 负责生成语句、解码结果,执行交给一个 sqlx 不支持的环境。

事务的四个钩子都有空的默认实现、没有返回值,也不带深度:嵌套 begin 只是再调一次 begin()。DatabaseTransaction 在 proxy 分支里调它们(start_rollback 是同步的,Drop 里调),但事务是不是真的事务,完全看你在钩子里做了什么。实验写一个转发到真实连接池的代理,钩子只打印:

#[derive(Debug)]
struct Forward {
  db: DatabaseConnection,
}

#[async_trait::async_trait]
impl ProxyDatabaseTrait for Forward {
  async fn query(&self, stmt: Statement) -> Result<Vec<ProxyRow>, DbErr> {
    println!("  proxy.query   {}", stmt.sql);
    let rows = self.db.query_all_raw(stmt).await?;
    Ok(rows.iter().map(from_query_result_to_proxy_row).collect())
  }
  async fn execute(&self, stmt: Statement) -> Result<ProxyExecResult, DbErr> {
    println!("  proxy.execute {}", stmt.sql);
    Ok(self.db.execute_raw(stmt).await?.into())
  }
  async fn begin(&self) { println!("  proxy.begin()"); }
  async fn rollback(&self) { println!("  proxy.rollback()"); }
  fn start_rollback(&self) { println!("  proxy.start_rollback()"); }
}

let db = Database::connect_proxy(DbBackend::Postgres, Arc::new(Box::new(Forward { db: real })))
  .await?;
// S:insert、find_by_id
// T
let txn = db.begin().await?;
item::ActiveModel { n: Set(2), ..Default::default() }.insert(&txn).await?;
let sp = txn.begin().await?;
sp.commit().await?;
txn.rollback().await?;
let txn = db.begin().await?;
item::ActiveModel { n: Set(3), ..Default::default() }.insert(&txn).await?;
drop(txn);
// U:select interval '1 day' as d
--- S: 经 ProxyDatabase 插入、查询 ---
  proxy.query   INSERT INTO "item" ("n") VALUES ($1) RETURNING "id", "n"
insert 返回 Model { id: 1, n: 1 }
  proxy.query   SELECT "item"."id", "item"."n" FROM "item" WHERE "item"."id" = $1 LIMIT $2
find_by_id 返回 Some(Model { id: 1, n: 1 })
--- T: 事务:begin、嵌套 begin、rollback、drop ---
  proxy.begin()
  proxy.query   INSERT INTO "item" ("n") VALUES ($1) RETURNING "id", "n"
  proxy.begin()
  proxy.rollback()
  proxy.begin()
  proxy.query   INSERT INTO "item" ("n") VALUES ($1) RETURNING "id", "n"
  proxy.start_rollback()
item.n = [1, 2, 3]
--- U: 一个 ProxyRow 不认识的列类型 ---
  proxy.query   select interval '1 day' as d

thread 'tokio-rt-worker' (3854271) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-2.0.4/src/driver/sqlx_postgres.rs:945:30:
internal error: entered unreachable code: Unknown column type: INTERVAL
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
结果 Err(true)
  • T:钩子按调用顺序被调到(sp.commit() 调的 commit 用的是默认空实现,所以没打印),但代理什么也没做,回滚了的 2 和被 drop 的 3 都留在表里。转发到池的代理要实现事务,得自己在 begin 里拿一条连接、记住深度、让之后的语句走这条连接,这正是 DatabaseTransaction 在 sqlx 分支里做的事。
  • U:把真实的 sqlx 行转成 ProxyRow 的 from_query_result_to_proxy_row,Postgres 分支是 from_sqlx_postgres_row_to_proxy_row:按列的类型名逐个 match,没列到的类型(这里是 INTERVAL)走 unreachable!,整个任务 panic。
📝NOTE
冷知识:proxy 连接以前开不了事务

ProxyDatabase 是 #2000「Database Proxy」加进来的(首次发布 0.12.10)。trait 上从一开始就有 begin / commit / rollback 三个空钩子,db.begin() 也会走到 new_proxy,但 DatabaseTransaction 的 begin、commit、rollback 里只有 sqlx 和 mock 的分支,proxy 连接落进 _ => Err(conn_err("Disconnected")):钩子永远调不到,db.begin() 返回 Disconnected 错误。#2573「Add proxy transaction impl」补上了四个 InnerConnection::Proxy 分支,并给 trait 加了同步的 start_rollback 钩子供 Drop 调用,说明只有一句「Support ProxyDatabase in DatabaseTransaction impl.」。CHANGELOG 记在 1.1.11(2025-05-07)。新钩子同样有空的默认实现,已有的代理不用改;不实现钩子的代理,事务就是上面 T 段那样一串普通语句。

动脑筋答案

--- Q: 事务、保存点、隔离级别、drop ---
  Transaction 0:
    BEGIN
    UPDATE "item" SET "n" = $1 WHERE "item"."id" = $2  [Int(Some(1)), Int(Some(1))]
    SAVEPOINT savepoint_1
    UPDATE "item" SET "n" = $1 WHERE "item"."id" = $2  [Int(Some(2)), Int(Some(1))]
    ROLLBACK TO SAVEPOINT savepoint_1
    UPDATE "item" SET "n" = $1 WHERE "item"."id" = $2  [Int(Some(3)), Int(Some(1))]
    COMMIT
  Transaction 1:
    BEGIN
    UPDATE "item" SET "n" = $1 WHERE "item"."id" = $2  [Int(Some(4)), Int(Some(1))]
    ROLLBACK

两个 Transaction,up(3) 夹在第一个事务里,没有 SET TRANSACTION。

  • MockDatabase 只有一个 transaction 槽,execute 一看它是 Some 就把语句推进去,不管语句是从 &db 还是 &txn 来的。真实 Postgres 上 up(3) 在另一条连接上自动提交,还会等 up(1) 持有的行锁(事务 的 K 段);mock 的日志却显示它在事务里,测试会以为它是原子的。
  • begin_with_config 的隔离级别和只读在 mock 分支被丢掉:TransactionTrait for DatabaseConnection 的 mock 臂调的是 new_mock(Arc::clone(conn), None),DatabaseTransaction::begin 的 mock 分支也不调 set_transaction_config。
  • 保存点叫 savepoint_1,不是 sqlx 的 _sqlx_savepoint_1;第一行的 BEGIN 不管后端是什么都用 DbBackend::Postgres 构造。拿 mock 日志断言保存点名字,断言的是 mock 自己的写法。
  • drop(txn) 当场记下 ROLLBACK,和真实连接上「排进写缓冲、归还时发出」的时机不同,但日志看不出时机,只看得出有这条语句。

小结

  • mock 是纯 cfg 开关:给 DatabaseConnectionType、InnerConnection、QueryResultRow、ExecResultHolder 各加一个分支。DatabaseConnectionType 不是 non_exhaustive,打开 mock 会让下游对 db.inner 的穷尽 match 编译失败。
  • 1.1.20 里 mock 还去掉 DatabaseConnection: Clone;本站 payment 的注释在 1.1.20 下成立,范围是编译它的测试(dev-dependency 的 feature 在 resolver 2 下只在构建测试时统一),2.0.0-rc.28 起 Clone 无条件派生。
  • MockDatabase 按调用序号从 query_results / exec_results 取结果,不看 SQL;Postgres 上带 RETURNING 的写入消耗的是查询队列。日志里是完整的 Statement($N 和 Values),用来断言生成了什么 SQL、几条、什么顺序。
  • mock 的事务是一个全局槽:&db 的语句会被记进开着的事务,隔离级别被丢掉,保存点叫 savepoint_N,Drop 当场记 ROLLBACK。类型转换失败一律报成「A null value was encountered」。
  • ProxyDatabaseTrait 把 Statement 交给你执行,事务钩子默认是空的,不带深度;from_query_result_to_proxy_row 碰到没列出的列类型会 panic。

💬 关联讨论

发起讨论
加载中...