事务借还与联表查询:ArkTS 多表操作在鸿蒙物品清单的实战



实例:家庭物品清单(Household)|技术:事务、LEFT JOIN、多条件查询
一、物品清单查询的三个技术点
家庭物品清单的数据层引入了前面实例没有的两类重磅技术:事务(borrowItem/returnItem 的多表原子写)与 LEFT JOIN(联表取物品名)。本篇文章逐一深入,重点讲 事务 与 联表查询——多表应用的基石。
二、borrowItem:事务保证多表写原子(核心)
借出 = 「写借用记录」+「改物品状态为借出」——两步必须同时成功(原子):
static async borrowItem(context: common.Context, itemId: number, borrower: string, note: string): Promise<boolean> {
const store = await HouseholdDao.getStore(context);
try {
await store.beginTransaction();
const borrowValues: relationalStore.ValuesBucket = {
item_id: itemId, borrower: borrower,
borrow_time: Date.now(), return_time: 0, note: note,
};
await store.insert(HouseholdDao.BORROW_TABLE, borrowValues);
const values: relationalStore.ValuesBucket = { status: 1 };
const predicates = new relationalStore.RdbPredicates(HouseholdDao.TABLE);
predicates.equalTo('id', itemId);
await store.update(values, predicates);
await store.commit();
return true;
} catch (e) {
try {
await store.rollBack();
} catch (e2) {
// 忽略
}
hilog.error(DOMAIN, TAG, `借用登记事务失败: ${e}`);
return false;
}
}
事务三件套的完整流程:
beginTransaction() ← 开启事务(之后的写操作暂存)
├── INSERT borrow_record(写借用流水)
├── UPDATE household_item SET status=1(改物品状态)
commit() ← 全部成功,一次性提交
└── 任一步失败 → catch → rollBack() ← 全部回滚(撤销之前的写)
为什么需要事务? 如果 INSERT 成功但 UPDATE 失败(无事务)——借用记录说「破壁机被王阿姨借走」但物品状态还是「在家」——数据不一致。事务让两步全有或全无(要么都成功、要么都撤销)。
**「多表联合写必须事务」**是数据一致性铁律——这也是本实例引入事务的原因(前面实例的单表写天然原子,一条 SQL 本身是事务)。
事务的三段式模式(可复用的模板):
try {
await store.beginTransaction();
// ... 多个写操作
await store.commit();
} catch (e) {
await store.rollBack(); // 回滚失败忽略
// 记录错误日志
}
rollBack 的二次 try-catch:回滚本身可能失败(连接异常)——catch (e2) {} 忽略(已尽力回滚)。错误日志:hilog.error(DOMAIN, TAG, ...)——回滚后记录失败原因(排查用)。
返回 boolean 而非抛异常:事务失败被内部捕获 → 返回 false → 页面提示「登记失败」——**「事务封装成布尔结果」**让调用方简单(不需要 try-catch)。
returnItem(归还):对称结构——更新借用记录 return_time + 物品状态回 0——同样事务包裹。
与级联删除的对比:deleteItem 是「先删借用记录再删物品」(两条 delete 无事务——Demo 简化)——「删除可用无事务顺序执行,状态流转必须事务」:删除失败可重试(幂等),状态流转失败产生不一致(必须原子)。
三、queryBorrows:LEFT JOIN 联表取名
借用记录要显示「物品名」,但借用表只有 item_id——联表查询:
static async queryBorrows(context: common.Context): Promise<BorrowRecord[]> {
const store = await HouseholdDao.getStore(context);
const result = await store.querySql(
`SELECT b.*, i.name AS item_name
FROM ${HouseholdDao.BORROW_TABLE} b
LEFT JOIN ${HouseholdDao.TABLE} i ON i.id = b.item_id
ORDER BY b.borrow_time DESC`
);
return HouseholdDao.collectBorrows(result);
}
SELECT b.*, i.name AS item_name
FROM borrow_record b
LEFT JOIN household_item i ON i.id = b.item_id
ORDER BY b.borrow_time DESC;
JOIN 的语义:把两张表按关联条件合并成一张宽表——ON i.id = b.item_id(借用表的 item_id 匹配物品表的 id)——每行借用记录拼接上对应物品的名字。
SELECT b.*, i.name AS item_name:取借用表全部列(b.*)+ 物品表的名字列(别名 item_name)——「投影:只取需要的列」(不把物品表全列拖进来)。
LEFT JOIN vs INNER JOIN:
| JOIN 类型 | 保留行 | 物品被删后 |
|---|---|---|
| INNER JOIN | 两表都匹配的行 | 借用记录消失 |
| LEFT JOIN | 左表(借用)全部行 | 记录保留,item_name=NULL |
为什么用 LEFT:借用历史是「审计记录」——即使物品被删了(级联删了借用?——deleteItem 会删,但如果先删物品再查,LEFT 保证还在的记录不丢)。「历史流水用 LEFT JOIN」(不因关联表缺行而丢记录)。
BorrowRecord.itemName 投影进实体:itemName: result.getString(...) || ''——联表列直接成为实体的字段(页面 b.itemName 直接显示)。
JOIN 的索引:idx_borrow_item (item_id) 加速关联匹配(ON i.id = b.item_id 的 b.item_id 查索引)。
四、queryFilter:多条件组合查询
物品筛选 = 分类 + 位置 + 状态的三维组合:
static async queryFilter(context: common.Context, category: string, location: string, status: number): Promise<HouseholdItem[]> {
const store = await HouseholdDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(HouseholdDao.TABLE);
if (category !== '全部') {
predicates.equalTo('category', category);
}
if (location !== '全部') {
predicates.like('location', `%${location}%`);
}
if (status >= 0) {
predicates.equalTo('status', status);
}
predicates.orderByDesc('status').orderByAsc('category');
const result = await store.query(predicates);
return HouseholdDao.collectItems(result);
}
三个可选条件的组合(与影音 queryFilter 同构,但维度不同):
| 条件 | 谓词 | 哨兵值 |
|---|---|---|
| 分类 | equalTo(‘category’, cat) | ‘全部’ 跳过 |
| 位置 | like(‘location’, ‘%kw%’) | ‘全部’ 跳过 |
| 状态 | equalTo(‘status’, s) | -1 跳过 |
「哨兵值跳过可选条件」:‘全部’/‘-1’ 表示不过滤——同一个方法承担「单条件/双条件/三条件」的所有组合。位置用 LIKE 模糊:搜「橱柜」命中「厨房橱柜」——「组合查询的维度可以是模糊匹配」。
排序 orderByDesc('status').orderByAsc('category'):借出在前(status 1 > 0 DESC)、同类按分类排——「借出的物品排前面」(提醒「这些不在家」)。
五、统计与删除
overview(条件计数):
static async overview(context: common.Context): Promise<HouseholdOverview> {
// SELECT COUNT(*) AS total,
// SUM(CASE WHEN status=1 THEN 1 ELSE 0 END) AS borrowed,
// SUM(CASE WHEN status=0 THEN 1 ELSE 0 END) AS at_home
// FROM household_item
}
SUM(CASE WHEN) 三统计——与错题本 overview 同构(总数/借出/在家一次扫描)。
categoryStats:GROUP BY category + COUNT——每分类物品数(与实例 16 同构)。
deleteItem(级联删除):
static async deleteItem(context: common.Context, id: number): Promise<void> {
const store = await HouseholdDao.getStore(context);
const borrowPred = new relationalStore.RdbPredicates(HouseholdDao.BORROW_TABLE);
borrowPred.equalTo('item_id', id);
await store.delete(borrowPred); // 先删借用记录
const itemPred = new relationalStore.RdbPredicates(HouseholdDao.TABLE);
itemPred.equalTo('id', id);
await store.delete(itemPred); // 再删物品
}
级联删除:删物品前先删它的借用记录(否则借用表留下悬空 item_id)——「先删子表再删主表」(与 CRM 客户-跟进同模式)。
六、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 事务 | begin/commit/rollBack 三件套 | 多表写原子 |
| 事务模板 | try-catch + boolean 返回 | 可复用封装 |
| LEFT JOIN | b.* + i.name AS item_name | 联表取名 |
| 可选条件 | 哨兵值跳过(‘全部’/-1) | 组合不爆炸 |
| 位置模糊 | like(‘%kw%’) | 位置搜索 |
| 借出在前 | orderByDesc(status) | 提醒不在家 |
| 级联删除 | 先子后主 | 无悬空引用 |
七、常见问题 FAQ
Q1:事务和普通写的区别到底是什么?
A:普通单条 INSERT/UPDATE 本身是「隐式事务」(一条语句原子);事务(beginTransaction)是显式包裹多条语句——要么全部提交(commit)、要么全部回滚(rollBack)。「多条语句的一致性靠显式事务」——单条语句不需要。
Q2:borrowItem 返回 boolean 和抛异常哪种好?
A:内部捕获 + 返回 boolean——调用方 if (ok) 判断即可(页面简单);抛异常让调用方 try-catch(页面复杂)。「内部封装的边界」:DAO 层把「失败」转成「布尔结果」是面向调用方的友好封装——「失败细节留在 DAO,结果语义给页面」。
Q3:LEFT JOIN 和子查询(先查借用再查名字)的区别?
A:LEFT JOIN 一条 SQL 拿全(含名字);子查询要「N 条借用 + N 次名字查询」。「联表一次拿全 vs 逐条补查」——JOIN 高效(一次往返),子查询代码简单但慢。「需要关联字段就 JOIN」。
Q4:物品被删除后借用记录怎么办?
A:deleteItem 先删借用记录(级联)——所以正常情况下不会有「记录在但物品没了」。LEFT JOIN 的 LEFT 语义是「防删除竞态」(极端情况记录残留也不丢)。「主动级联 + JOIN 兜底」双保险。
Q5:状态筛选「在家」点两次为什么变「全部」?
A:toggle 语义——statusFilter === s ? -1 : s(再点取消)——「可取消的筛选」(与影音/错题本同模式)。用户可能想「先看看在家、再看全部」——toggle 让状态筛选可脱离。
Q6:位置筛选用 LIKE 而不是等值?
A:位置是自由文本(厨房橱柜/书房书桌)——等值匹配太死(搜「厨房」匹配不到「厨房橱柜」);LIKE 包含匹配让「搜厨房命中厨房橱柜/厨房台面」。「自由文本用 LIKE,枚举用 equalTo」。
Q7:returnItem 为什么不校验「确实是借出状态」?
A:页面 returnBorrow 已守卫(returnTime !== 0 跳过)+ returnItem 事务更新「id 匹配且未还」的记录——严格场景可加 equalTo('return_time', 0) 条件(防重复归还)。「页面守卫 + 事务」足够(教学简化)。
八、文章小结
本篇文章深入讲解了物品清单的数据层核心:事务三件套(begin/commit/rollBack 保证「写流水 + 改状态」的原子性 + 可复用的 try-catch 模板 + boolean 封装)、LEFT JOIN 联表取物品名(投影 item_name + LEFT 保留历史)、多条件组合查询(哨兵值跳过可选 + 位置 LIKE 模糊 + 借出在前排序)、级联删除(先子后主)。核心心法是「多表写必须事务、跨表取信息用 JOIN」——多表应用的基石,也是实例 20 收官的重要技术。
下一篇(20-4)展示 16 件物品 + 5 条借用记录种子数据,让清单一开屏就有借还闭环。
九、事务的生命周期:begin → commit/rollBack 状态机
事务不是「一条命令」,而是一段有明确状态的生命周期:
IDLE(空闲)──beginTransaction()──▶ ACTIVE(进行中)
ACTIVE ──commit()──▶ COMMITTED(已提交,结束)
ACTIVE ──rollBack()──▶ ROLLED_BACK(已回滚,结束)
ACTIVE ──异常未处理──▶ 连接关闭时自动回滚
状态机的四个状态:
| 阶段 | 状态 | 含义 |
|---|---|---|
| begin 之前 | IDLE | 每条 SQL 独立隐式事务 |
| begin 之后 | ACTIVE | 写操作暂存,未落盘 |
| commit 之后 | COMMITTED | 全部写操作一次性落盘 |
| rollBack 之后 | ROLLED_BACK | 暂存写操作全部撤销 |
commit 与 rollBack 是互斥终态:一次事务只能二选一。commit 之后不能再 rollBack(已落盘);rollBack 之后事务结束,需要重新 begin。「一次 begin 必须对应一次 commit 或 rollBack」——漏掉任何一方都会让事务悬挂(连接占用、锁不释放)。可见性:ACTIVE 期间其他连接读不到未提交的写——「未 commit 的写对外不可见」,这正是原子性的另一面。
十、LEFT JOIN 与 INNER JOIN 的实战对比
差别只在「不匹配的行留不留」。假设借用表 5 条里 1 条的物品已被删:
-- LEFT JOIN:5 行,其中 1 行 item_name = NULL
SELECT b.id, b.borrower, i.name AS item_name
FROM borrow_record b
LEFT JOIN household_item i ON i.id = b.item_id;
-- INNER JOIN:4 行,那条记录直接消失
SELECT b.id, b.borrower, i.name AS item_name
FROM borrow_record b
INNER JOIN household_item i ON i.id = b.item_id;
| 对比项 | INNER JOIN | LEFT JOIN |
|---|---|---|
| 结果行数 | 只保留两表都匹配的 | 左表全保留 |
| 物品被删后 | 借用记录「消失」 | 保留,item_name=NULL |
| 适用场景 | 只要有效关联(如在借物品统计) | 历史审计(借用流水不能丢) |
| 性能 | 略快(可跳过不匹配行) | 同索引下基本无差 |
选型口诀:「主表行必须全出用 LEFT,只要有效配对用 INNER」。借用记录是审计流水——LEFT;统计「当前在借的物品」——INNER 更干净(孤儿记录不该计入)。
十一、级联删除的顺序与事务选择
deleteItem 的两步顺序有讲究:
DELETE FROM borrow_record WHERE item_id = ? ── ① 先删子表(借用记录)
DELETE FROM household_item WHERE id = ? ── ② 再删主表(物品)
为什么不能反序:先删物品后,借用表的 item_id 变成悬空引用(指向不存在的物品)——下次 LEFT JOIN 这行 item_name = NULL,语义混乱。为什么这里不用事务:两步 delete 幂等(删不存在的行不报错)——失败重试即可,不会产生不一致的中间状态。「可重试的写不必事务,会产生不一致的写必须事务」——判断是否加事务的实用标准。隐藏的坑:若借用表有外键约束,先删主表直接报约束错误——本实例用「应用层级联」(代码手动先子后主),顺序必须在代码里保证。
十二、FAQ 补充
Q8:事务嵌套 begin 会怎样?
A:RDB 不支持嵌套事务——重复 beginTransaction 会抛异常或忽略。「一个连接同时只有一个事务」:需要嵌套用「保存点」(本实例不涉及,知道概念即可)。
Q9:commit 失败(磁盘满/锁冲突)怎么办?
A:commit 抛异常会进 catch → rollBack 兜底——但此时部分数据可能已落盘(commit 是「尽力而为」)。「commit 失败靠业务重试,rollBack 是最后防线」。
Q10:LEFT JOIN 性能差吗?
A:关键看 ON 条件是否有索引——idx_borrow_item (item_id) 让关联走索引,16 件物品 + 5 条记录的规模毫秒级。「JOIN 的性能瓶颈在索引,不在 JOIN 本身」。
更多推荐




所有评论(0)