导出:删掉到访后,别把它指向的原始采样点留成悬空引用
真机报「备份恢复失败,你的当前数据未被更改」——这句说明它是先校验、整体回滚。 往下查的第一个醒悟:**Core Data 的 SQLite 表里根本没有外键约束**(关系由 Core Data 自己维护),所以之前跑的 PRAGMA foreign_key_check 报 0 违例毫无意义,它压根没得可查。 按关系列手查悬空引用,结果: 原版:悬空 0 导出:ZRAWVISIT.ZVISIT 有 2 条指向不存在的 ZVISIT —— (2051→到访46, 465→到访200) 正是用户在站点上删掉的那两条到访。ZRAWVISIT(原始 GPS 采样点)是不托管实体、 导出时原样保留,于是删到访就留下了指向已删记录的悬空关系;而真机删到访时 采样点是跟着级联删的,所以这是它自己绝不会产生的形状。 修法:visit.finish() 之后,删掉 ZVISIT 指向不存在到访的 ZRAWVISIT 行。 选「删」而不是「把 ZVISIT 置空」:真机的「未归并采样点」虽有 1461/2246 条(置空合法), 但留成未归并会让 app 重新推出一条到访,把用户删掉的记录复活。 顺带加固: - verify_export_structure.py 新增 1.45 节「关系完整性」,手查 8 组关系列的悬空引用, 有悬空就把结论判成「有差异」——这条 foreign_key_check 查不出来的坑进了标准验收清单。 - TestExportDropsOrphanedRawVisits:删一条带采样点的到访 → 导出 → 断言没有悬空;已做变异验证。
This commit is contained in:
@@ -460,6 +460,14 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
|
|||||||
if err := visit.finish(); err != nil {
|
if err := visit.finish(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 站点删掉到访之后,它的原始采样点会失去目标。ZRAWVISIT 是不托管实体(原样保留),
|
||||||
|
// 而 Core Data 的关系由它自己维护、SQLite 里没有外键约束,所以这种悬空引用不会报错,
|
||||||
|
// 但它是**真机自己绝不会产生的形状**——真机删到访时采样点跟着级联删。
|
||||||
|
// 留着还有副作用:app 会把「没归并的采样点」重新推成一条到访,删掉的记录又回来了。
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM ZRAWVISIT WHERE ZVISIT IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM ZVISIT v WHERE v.Z_PK = ZRAWVISIT.ZVISIT)`); err != nil {
|
||||||
|
return fmt.Errorf("清理失去目标的原始采样点失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 8) 访问↔标签关联(列名从基线探测得到;纯两列关系,全量重写即可)
|
// 8) 访问↔标签关联(列名从基线探测得到;纯两列关系,全量重写即可)
|
||||||
if join.Name != "" && join.VisitCol != "" && join.TagCol != "" {
|
if join.Name != "" && join.VisitCol != "" && join.TagCol != "" {
|
||||||
|
|||||||
@@ -115,3 +115,61 @@ func countNull(t *testing.T, db *sql.DB, q string, args ...any) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExportDropsOrphanedRawVisits 守「导出包的关系图不许有悬空引用」。
|
||||||
|
//
|
||||||
|
// ZRAWVISIT(原始 GPS 采样点)是我们**不托管**的实体,导出时原样保留;而站点删掉一条到访时,
|
||||||
|
// 指向它的采样点就留成了悬空 —— Core Data 的关系由它自己维护、SQLite 里没有外键约束,
|
||||||
|
// 所以 `foreign_key_check` 查不出来,但它是真机自己绝不会产生的形状(真机删到访时采样点级联删)。
|
||||||
|
// 留着还有副作用:app 会把「没归并的采样点」重新推成一条到访,删掉的记录又回来了。
|
||||||
|
func TestExportDropsOrphanedRawVisits(t *testing.T) {
|
||||||
|
f := newMovementFixture(t)
|
||||||
|
srcDB := f.openSource(t)
|
||||||
|
|
||||||
|
// 挑一条「有原始采样点」的到访:原始采样点的 ZVISIT 就是到访的 Z_PK(= 站点的 src_pk)
|
||||||
|
var visitPK int
|
||||||
|
if err := srcDB.QueryRow(`SELECT ZVISIT FROM ZRAWVISIT
|
||||||
|
WHERE ZVISIT IS NOT NULL GROUP BY ZVISIT ORDER BY ZVISIT LIMIT 1`).Scan(&visitPK); err != nil {
|
||||||
|
t.Skipf("基线里没有带采样点的到访,跳过: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
post(t, f.srv.editVisitDelete, f.ctx, "/admin/edit/visit/delete", map[string][]string{
|
||||||
|
"src_pk": {fmt.Sprint(visitPK)},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := filepath.Join(t.TempDir(), "export.rondbackup")
|
||||||
|
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
edb, err := rond.Open(out, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开导出包失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { edb.Close() })
|
||||||
|
|
||||||
|
if n := countNull(t, edb, `SELECT COUNT(*) FROM ZRAWVISIT WHERE ZVISIT=?`, visitPK); n != 0 {
|
||||||
|
t.Errorf("删掉到访 #%d 后,它的 %d 条原始采样点还留着(悬空引用)", visitPK, n)
|
||||||
|
}
|
||||||
|
// 通用判据:整包不许有任何悬空引用(Core Data 自己维护关系,pragma 查不到)
|
||||||
|
for _, ref := range unmanagedRefs {
|
||||||
|
q := fmt.Sprintf(`SELECT COUNT(*) FROM %s x WHERE x.%s IS NOT NULL AND x.%s<>0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM %s y WHERE y.Z_PK = x.%s)`,
|
||||||
|
ref[0], ref[1], ref[1], ref[2], ref[1])
|
||||||
|
if n := countNull(t, edb, q); n > 0 {
|
||||||
|
t.Errorf("%s.%s 有 %d 条指向不存在的 %s —— 真机从不产生悬空关系", ref[0], ref[1], n, ref[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmanagedRefs 是「不托管的表指向托管实体」的关系列(库、列、目标表)。
|
||||||
|
// 加新表时照这个格式补,通用判据会自动覆盖。
|
||||||
|
var unmanagedRefs = [][3]string{
|
||||||
|
{"ZRAWVISIT", "ZVISIT", "ZVISIT"},
|
||||||
|
{"ZRAWVISIT", "ZLOCATION", "ZLOCATION"},
|
||||||
|
{"ZRAWVISIT", "ZACTIVITY", "ZACTIVITY"},
|
||||||
|
{"ZSTAYSEGMENT", "ZLOCATION", "ZLOCATION"},
|
||||||
|
{"ZUSERPHOTO", "ZVISIT_", "ZVISIT"},
|
||||||
|
{"ZVISITPHOTOFAVORITE", "ZVISIT_", "ZVISIT"},
|
||||||
|
{"ZKEYWORD", "ZACTIVITY_", "ZACTIVITY"},
|
||||||
|
{"ZPOICATEGORY", "ZACTIVITY_", "ZACTIVITY"},
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,30 @@ def compare_rondbackup(orig, new, new2=None):
|
|||||||
stale += 1
|
stale += 1
|
||||||
say("对照:原版自身「缓存 != MAX(到访到达)」的地点 %d 个(原版缓存陈旧,导出是修正)" % stale)
|
say("对照:原版自身「缓存 != MAX(到访到达)」的地点 %d 个(原版缓存陈旧,导出是修正)" % stale)
|
||||||
|
|
||||||
|
# 悬空关系引用。**必须手查**:Core Data 的关系由它自己维护,SQLite 里根本没有
|
||||||
|
# 外键约束,所以 PRAGMA foreign_key_check 永远是 0 违例,查不出这类问题。
|
||||||
|
# 典型场景:站点删掉一条到访,它的 ZRAWVISIT 采样点(不托管实体)就失去了目标。
|
||||||
|
head("1.45 关系完整性(有没有指向已删记录的悬空引用)")
|
||||||
|
REFS = [("ZRAWVISIT", "ZVISIT", "ZVISIT"), ("ZRAWVISIT", "ZLOCATION", "ZLOCATION"),
|
||||||
|
("ZRAWVISIT", "ZACTIVITY", "ZACTIVITY"), ("ZSTAYSEGMENT", "ZLOCATION", "ZLOCATION"),
|
||||||
|
("ZUSERPHOTO", "ZVISIT_", "ZVISIT"), ("ZVISITPHOTOFAVORITE", "ZVISIT_", "ZVISIT"),
|
||||||
|
("ZKEYWORD", "ZACTIVITY_", "ZACTIVITY"), ("ZPOICATEGORY", "ZACTIVITY_", "ZACTIVITY")]
|
||||||
|
for tag, con in (("原版", co), ("导出", cn)):
|
||||||
|
bad = 0
|
||||||
|
for t, col, target in REFS:
|
||||||
|
try:
|
||||||
|
n = con.execute("SELECT COUNT(*) FROM %s x WHERE x.%s IS NOT NULL AND x.%s<>0 "
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM %s y WHERE y.Z_PK=x.%s)"
|
||||||
|
% (t, col, col, target, col)).fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if n:
|
||||||
|
say(" !! %s:%s.%s 有 %d 条指向不存在的 %s(真机从不产生悬空关系)" % (tag, t, col, n, target))
|
||||||
|
FAILED[0] = True
|
||||||
|
bad += n
|
||||||
|
if not bad:
|
||||||
|
say(" %s:悬空引用 0 ✓" % tag)
|
||||||
|
|
||||||
head("1.5 确定性(同一份数据导出两次是否一致)")
|
head("1.5 确定性(同一份数据导出两次是否一致)")
|
||||||
if new2 and os.path.exists(new2):
|
if new2 and os.path.exists(new2):
|
||||||
def dig(p):
|
def dig(p):
|
||||||
|
|||||||
Reference in New Issue
Block a user