07 SQLite 与 rusqlite:Repository 模式与类型转换
本章目标
- 你能读懂
asset-light的数据库初始化/迁移/表结构。 - 你能理解 rusqlite 的基本用法:参数化查询、
prepare、query_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.rs → Database::db_path()
- 支持
ASSET_LIGHT_DB_PATH环境变量(用于测试/临时 DB) - 默认用
dirs::data_local_dir(),在 macOS 通常会落到用户目录的 Application Support 下
建议你把它当作“调试开关”记住:
想要一个干净 DB,不必删真实数据,只要换一个 DB 路径即可。
2. 迁移策略:schema_version 与破坏性升级
迁移函数:src/db/connection.rs → Database::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-DDTEXT(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)
它会:
- 插入
snapshots - 循环插入多条
snapshot_items
如果中途失败,就会出现“主表写进去了,明细只写了一部分”的不一致。
更稳健的方式是使用事务:
- BEGIN
- 全部 insert 成功 → COMMIT
- 任一步失败 → ROLLBACK
rusqlite 支持 conn.transaction()(你可以把这作为本章的进阶练习)。
6. 本章练习(从小到大)
练习 A:用一个临时 DB 跑通 CRUD
- 用
ASSET_LIGHT_DB_PATH=/tmp/asset-light-dev.db启动应用 - 新增资产 → 关闭 → 再启动 → 确认数据仍在
- 删除资产 → 再启动 → 确认删除生效
练习 B:给 SnapshotRepository::create 加事务(进阶)
目标:让创建快照“要么全成功,要么全失败”,避免半写入。
练习 C:写一个最小集成测试(进阶)
思路:
- 测试里设置
ASSET_LIGHT_DB_PATH指向临时文件 - 调用
db::init_database() - 调用
AssetRepository::insert/find_all断言结果
你会同时练到:
- 环境变量注入
- DB 初始化
- Repository 行为验证