07 SQLite 与 rusqlite:Repository 模式与类型转换

本章目标

  • 你能读懂 asset-light 的数据库初始化/迁移/表结构。
  • 你能理解 rusqlite 的基本用法:参数化查询、preparequery_map、类型转换。
  • 你能看出当前实现的工程权衡点,并能做小步改进(例如事务、错误类型、测试)。

1. 本项目的 DB 初始化链路

1.1 初始化入口

入口:src/main.rs

  • 启动时先调用 db::init_database()
  • 通过后才启动 Dioxus UI

DB 初始化逻辑:src/db/mod.rs

  • OnceLock<Database> 保存全局单例
  • Database::new() 建立连接
  • db.run_migrations() 执行迁移
  • db.ensure_default_plan() 确保默认配置方案存在

1.2 DB 文件路径与环境变量

路径逻辑:src/db/connection.rsDatabase::db_path()

  • 支持 ASSET_LIGHT_DB_PATH 环境变量(用于测试/临时 DB)
  • 默认用 dirs::data_local_dir(),在 macOS 通常会落到用户目录的 Application Support 下

建议你把它当作“调试开关”记住:
想要一个干净 DB,不必删真实数据,只要换一个 DB 路径即可。


2. 迁移策略:schema_version 与破坏性升级

迁移函数:src/db/connection.rsDatabase::run_migrations()

关键点:

  • 创建 schema_meta 表维护 schema_version
  • 当前实现存在一次性“破坏性升级”:
    • current_version < 2 时会 DROP TABLE ...(会清空历史数据)
    • 然后重建 assets/snapshots/... 等表

这在“学习/快速迭代阶段”很合理(你能快速重塑数据结构),
但当你开始把它当成长期使用的个人工具时,应该升级为“非破坏性迁移”:

  • 新增列/表:ALTER TABLE ADD COLUMN ...
  • 数据回填:在迁移里做一次性 UPDATE
  • 保留历史数据:避免 drop

3. 表结构与字段编码(理解为什么都存 TEXT)

3.1 为什么大量字段使用 TEXT

SQLite 本身类型系统比较宽松,而 Rust 的类型系统很严格。
为了减少“数据库类型与 Rust 类型对齐”的复杂度,本项目选择:

  • Uuid:存 TEXT(uuid.to_string()
  • Decimal:存 TEXT(decimal.to_string(),避免浮点误差)
  • DateTime<Utc>:存 RFC3339 TEXT(to_rfc3339()
  • NaiveDate:存 YYYY-MM-DD TEXT(to_string()
  • enum:存稳定编码 TEXT(Category::as_str() / AssetScope::as_str() / VehicleType::as_str()

这让持久化变成“稳定字符串协议”,而解析逻辑集中在 Repository。

3.2 核心表(概念图)

(以 connection.rs 的建表 SQL 为准)

  • assets:资产条目
  • snapshots:盘点快照(主表)
  • snapshot_items:盘点快照明细
  • allocation_plans:配置方案(主表)
  • allocations:配置项(方案明细)
  • schema_meta:schema 版本

4. Repository 模式:把 SQL 与类型转换封装起来

本项目的 Repository 位于:src/db/*_repo.rs

共同特点:

  • 通过 get_database().with_conn(|conn| { ... }) 获取连接并执行闭包
  • SQL 一律使用参数化绑定(?1 ?2 ...),避免 SQL 注入与字符串拼接错误
  • 查询时使用 prepare + query_map,并在 row_to_xxx 中集中做类型转换

4.1 示例:AssetRepository(读写资产表)

文件:src/db/asset_repo.rs

常见方法:

  • insert(&Asset)
  • update(&Asset)
  • find_all() -> Vec<Asset>
  • find_by_id(Uuid) -> Option<Asset>

重点看 row_to_asset(row)

  • 把 DB 的字符串字段解析成 Uuid/Decimal/DateTime
  • scope/category/vehicle_type 的编码解析回 enum

4.2 示例:SnapshotRepository(快照主表 + 明细表)

文件:src/db/snapshot_repo.rs

值得注意的点:

  • find_all() 先查快照主表,再按 snapshot_id 查明细
  • 明细查询用内部函数 find_items_internal(conn, snapshot_id),复用同一连接

这种写法的优点是实现简单,但数据量很大时会产生 N+1 查询问题。
学习期先接受它,后续可用 join/批量查询优化。

4.3 示例:PlanRepository(方案与配置项)

文件:src/db/plan_repo.rs

值得注意的点:

  • set_active(plan_id) 先把所有方案置为非激活,再激活目标方案(保证“只有一个激活”)
  • 方案更新时先删旧 allocations 再插入新 allocations(简单可靠)

5. 事务(Transaction):让“多步写入”变成原子操作

一个非常典型的改进点:SnapshotRepository::create(snapshot)

它会:

  1. 插入 snapshots
  2. 循环插入多条 snapshot_items

如果中途失败,就会出现“主表写进去了,明细只写了一部分”的不一致。
更稳健的方式是使用事务:

  • BEGIN
  • 全部 insert 成功 → COMMIT
  • 任一步失败 → ROLLBACK

rusqlite 支持 conn.transaction()(你可以把这作为本章的进阶练习)。


6. 本章练习(从小到大)

练习 A:用一个临时 DB 跑通 CRUD

  1. ASSET_LIGHT_DB_PATH=/tmp/asset-light-dev.db 启动应用
  2. 新增资产 → 关闭 → 再启动 → 确认数据仍在
  3. 删除资产 → 再启动 → 确认删除生效

练习 B:给 SnapshotRepository::create 加事务(进阶)

目标:让创建快照“要么全成功,要么全失败”,避免半写入。

练习 C:写一个最小集成测试(进阶)

思路:

  • 测试里设置 ASSET_LIGHT_DB_PATH 指向临时文件
  • 调用 db::init_database()
  • 调用 AssetRepository::insert/find_all 断言结果

你会同时练到:

  • 环境变量注入
  • DB 初始化
  • Repository 行为验证