在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

实例:家庭物品清单(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 同构(总数/借出/在家一次扫描)。

categoryStatsGROUP 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 本身」

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐