【寻迹校园 HarmonyOS NEXT 实战 14】RelationalStore 增量迁移:为旧失物记录安全补上 event_date 字段

这是“寻迹校园 HarmonyOS NEXT 实战”系列第 14 篇。本文结合 ReportRepository.ets 的真实实现,说明为什么升级表结构要先读取 PRAGMA table_info,只在缺列时执行 ALTER TABLE,并为旧记录提供可解释的日期回填策略。

RelationalStore event_date 增量迁移原创封面图

上图为本文原创生成的迁移概念图,不是数据库截图。旧表没有 event_date 时才新增该列;已经升级过的数据库直接跳过,保证应用第二次、第三次启动仍然安全。

一、为什么失物记录需要 event_date

早期记录只有 time_labelcreated_at

  • time_label 适合展示“今天 08:00”“昨天 15:20”;
  • created_at 表示记录创建时间;
  • 真正的物品丢失或拾得日期可能早于发布时间。

匹配逻辑如果只看创建时间,会把“今天补发昨天丢失的物品”误认为今天事件。为了稳定计算候选日期差,模型新增绝对日期 event_date,格式为 YYYY-MM-DD

新安装可以直接按新表创建;真正困难的是已经存在旧数据库的用户。不能删除旧表重建,也不能假设 CREATE TABLE IF NOT EXISTS 会给旧表自动补列。

二、CREATE TABLE IF NOT EXISTS 不会改造旧结构

项目的新建表 SQL 已包含 event_date TEXT NOT NULL DEFAULT ''

但当 item_report 已经存在时,CREATE TABLE IF NOT EXISTS 只保证表存在,不会把新定义与旧结构比较后自动添加字段。

因此初始化需要两步:

  1. 执行 CREATE TABLE IF NOT EXISTS,覆盖首次安装;
  2. 对旧表逐列检查,缺失时执行增量迁移。

把这两类场景分开,才能同时支持新装和升级。

三、先用 PRAGMA table_info 读取真实列结构

ensureColumn() 不信任代码中的期望结构,而是查询设备上当前数据库:

const resultSet: relationalStore.ResultSet =
  await store.querySql(`PRAGMA table_info(${TABLE_REPORT})`);
let exists: boolean = false;
try {
  while (resultSet.goToNextRow()) {
    if (resultSet.getString(resultSet.getColumnIndex('name')) === column) {
      exists = true;
      break;
    }
  }
} finally {
  resultSet.close();
}

ResultSet 必须关闭。即使找到目标列后提前 breakfinally 仍然执行,避免资源泄漏。

这种方式把“是否迁移”的判断建立在真实 Schema 上,而不是进程内布尔值、版本号猜测或异常文本。

四、只有缺列时才执行 ALTER TABLE

检查完成后逻辑很明确:

if (!exists) {
  await store.executeSql(
    `ALTER TABLE ${TABLE_REPORT} ADD COLUMN ${column} ${definition}`
  );
}

首次从旧版本升级:列不存在,执行一次 ALTER TABLE。以后启动:列已存在,直接跳过。

这就是幂等迁移的核心:同一个初始化流程执行多次,最终结构保持一致,不会因为重复运行产生错误。

五、为什么不能“直接 ALTER,报错就 catch”

另一种常见写法是每次启动都执行 ALTER TABLE ADD COLUMN,如果出现 duplicate column 就捕获忽略。项目中的 ClaimRepository 目前仍能看到这种历史写法:无条件添加 source_report_id,再用 try/catch 吞掉已有列错误。

它能让流程继续,却有明显维护风险:

  • 每次启动都触发一个可预期异常;
  • catch 可能同时吞掉权限、SQL 拼写、存储损坏等真正问题;
  • 日志会反复出现噪声,掩盖首条真实错误;
  • 代码无法明确证明“列已存在”还是“迁移失败被忽略”;
  • 不利于后续统计迁移成功率。

本文只记录这个真实风险,不宣称已经修复 ClaimRepository。更稳妥的后续动作是复用 ensureColumn,让异常只代表真正异常。

RelationalStore 列检查、回填与重复列风险原创序列图

上图展示推荐链路与风险分支:读取列结构,缺列才新增,旧值再回填;无条件 ALTER 则会在二次启动进入重复列错误。

六、DEFAULT 空字符串解决的是结构兼容,不是业务语义

新增列使用 TEXT NOT NULL DEFAULT ''

这样旧行在加列后有合法默认值,不会立即违反非空约束。但空字符串不是真实事件日期,只表示“旧数据尚无明确值”。

因此读取记录时,项目还要处理业务回填:若 storedEventDate 非空就使用;否则执行 this.formatDate(createdAt)

这是一种可解释的兼容策略:旧数据没有事件日期时,以创建日期近似。它不是还原历史真相,产品与文档都不应把推导值说成用户原始填写值。

七、读取时回填与一次性数据更新如何选择

项目当前在 readReport() 中动态回填。优点是迁移简单,不需要批量更新旧行;缺点是数据库里的旧值继续为空,每次读取都要走兼容分支。

另一种方案是 Schema 迁移后执行一次数据更新,把 created_at 转成日期写入 event_date。这种方式查询更统一,但需要:

  • 明确时间戳时区;
  • 在事务中批量更新;
  • 记录迁移版本与完成状态;
  • 处理部分失败与重试;
  • 说明推导值来源。

当前数据量和单机场景下,读取时回填是较小改动。未来需要按日期建立索引或做复杂查询时,再升级为一次性持久化迁移更合适。

八、formatDate 也有边界条件

formatDate() 使用本地 Date,通过 padStart(2, '0') 补齐月和日,再拼成稳定的 YYYY-MM-DD

当时间戳小于等于 0 时,当前实现回退到 Date.now()。这能避免生成 1970 年异常展示,但也会隐藏脏数据来源。

生产级迁移应统计非法时间戳、记录受控告警,并决定使用“未知日期”、创建日期还是要求用户重新确认。不能让所有损坏记录静默变成今天。

九、schemaReady 不等于数据库版本

Repository 中的 schemaReady 用于避免同一对象重复检查:

if (!this.schemaReady) {
  await this.store.executeSql(CREATE_REPORT_TABLE_SQL);
  await this.ensureColumn(this.store, 'event_date', "TEXT NOT NULL DEFAULT ''");
  await this.ensureColumn(this.store, 'image_uris', "TEXT NOT NULL DEFAULT ''");
  this.schemaReady = true;
}

应用重启后它会恢复为 false,所以它只是进程内优化,不能作为迁移已完成的永久证据。真正的幂等性来自 PRAGMA table_info 与缺列判断。

当迁移数量增加时,应考虑正式 Schema 版本表或 user_version,按版本顺序执行并记录完成状态。每个迁移仍应尽量可重试、可验证。

十、迁移失败不能静默进入不兼容查询

如果新增列失败,但后续 readReport() 仍直接读取 event_date,查询会出现列不存在错误。初始化应把迁移失败作为存储层错误处理:

  • 不把 schemaReady 设为 true;
  • 返回稳定错误分类;
  • 不执行依赖新列的写入;
  • 页面展示可重试状态;
  • 受控日志记录迁移步骤和列名,不打印用户数据;
  • 下一次启动能够重新尝试。

“继续运行”不是所有场景的正确降级。如果旧 Schema 无法满足当前代码契约,静默回退可能造成更难定位的数据错误。

十一、迁移验收必须覆盖多种起点

至少准备以下数据库状态:

起点 操作 预期结果
无数据库 首次启动 创建完整新表
旧表无 event_date 升级启动 新增列,旧行可读取
新表已有 event_date 再次启动 不执行重复 ALTER
旧行 event_date 为空 读取记录 使用 createdAt 推导值
新行有绝对日期 读取记录 保留原值
createdAt 非法 读取旧行 按约定显示未知或受控回退
迁移 SQL 失败 初始化 不标记完成,用户收到存储错误

还应连续启动两次,验证第二次没有 duplicate column;再执行新建、编辑和列表筛选,确认新列不仅存在,而且业务链路正确消费。

十二、备份、回滚与数据安全

ADD COLUMN 通常是向后兼容修改,但交付前仍要准备:

  • 升级前保留可恢复测试数据库;
  • 对迁移脚本做旧版样本回放;
  • 确认旧应用若回滚,是否会忽略新增列;
  • 不在失败时删除整库或清空表;
  • 记录迁移版本、耗时和失败分类;
  • 对真实用户数据避免输出明文日志。

如果新版必须回滚,新增列通常可以保留,让旧代码忽略它;不要为了“恢复旧结构”贸然重建表并搬运数据。结构回滚的风险往往大于保留一个未使用列。

十三、变更影响分析不能只看 Repository

加入 event_date 还会影响:

  • ItemReport 模型构造参数;
  • ReportDraft 与草稿表;
  • 发布页日期选择器和 Service 校验;
  • 匹配规则中的日期差;
  • 种子数据;
  • 编辑旧记录;
  • 测试夹具和导入导出;
  • 后续 OpenAPI 或云同步 DTO。

字段迁移是端到端契约变更。只改建表 SQL而不检查模型和查询,最容易出现“能编译、启动后才崩”的问题。

十四、当前项目的真实结论

ReportRepositoryReportDraftRepository 已采用“查询列结构、缺失才新增”的方式处理 event_dateimage_uris。代码层可以确认迁移设计具备幂等意图。

ClaimRepository 仍存在无条件添加 source_report_id 再吞异常的实现,这是已识别风险,不在本文中伪装成已修复。真实设备上的旧库升级、连续冷启动和数据样本回放仍需单独留证。

十五、本文小结

RelationalStore 增量迁移不能只修改 CREATE TABLE。安全链路应读取 PRAGMA table_info,确认列缺失后才执行 ALTER TABLE;新增列用默认值保障结构兼容,再以明确策略处理旧行语义。

进程内 schemaReady 只是优化,真正的二次启动安全来自幂等迁移。下一篇将讨论另一个容易误读的设计:同一个 Repository 为什么同时支持 RelationalStore 与内存回退,以及两种数据源各自能证明什么。

系列导航:第 14 篇 / 共 50 篇。上一篇:《按 LOST/FOUND 隔离草稿恢复》;下一篇:《Repository 双数据源策略》。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐