#!/usr/bin/env python3 """对导出的 .rondbackup 做「能不能被 Core Data 读回」的深度取证。 与 tools/verify_export_structure.py 互补:那个比结构与部分不变量,这个专查两件事—— 1. 逐列空值画像:**参照包里某列一个 NULL 都没有,导出包里却出现 NULL/空值** ⇒ Core Data 会以「备份恢复失败」拒收(非可选属性上它自己从不产生 NULL)。 2. 全部关系列的悬空引用(技能里列的每一列都查)。 用法:python tools/audit_export_nulls.py <参照.rondbackup> <导出.rondbackup> """ import os import sqlite3 import sys import tempfile import zipfile MANAGED = ["ZVISIT", "ZLOCATION", "ZACTIVITY", "ZMOVEMENT", "ZHOURLYWEATHER", "ZTAG", "ZTRANSPORT"] # 关系列 -> 目标表(按技能 1.45 节) RELS = [ ("ZVISIT", "ZLOCATION", "ZLOCATION"), ("ZVISIT", "ZACTIVITY_", "ZACTIVITY"), ("ZVISIT", "ZMERGEDTO", "ZVISIT"), ("ZVISIT", "ZPARENT", "ZVISIT"), ("ZVISIT", "ZTRIPSEGMENT_", "ZTRIPSEGMENT"), ("ZRAWVISIT", "ZVISIT", "ZVISIT"), ("ZRAWVISIT", "ZLOCATION", "ZLOCATION"), ("ZRAWVISIT", "ZACTIVITY", "ZACTIVITY"), ("ZSTAYSEGMENT", "ZLOCATION", "ZLOCATION"), ("ZSTAYSEGMENT", "ZPARENTVISIT", "ZVISIT"), ("ZUSERPHOTO", "ZVISIT_", "ZVISIT"), ("ZVISITPHOTOFAVORITE", "ZVISIT_", "ZVISIT"), ("ZKEYWORD", "ZACTIVITY_", "ZACTIVITY"), ("ZPOICATEGORY", "ZACTIVITY_", "ZACTIVITY"), ("ZMOVEMENT", "ZVISITFROM_", "ZVISIT"), ("ZMOVEMENT", "ZVISITTO_", "ZVISIT"), ("ZMOVEMENT", "ZTRANSPORT_", "ZTRANSPORT"), ("ZHOURLYWEATHER", "ZVISIT", "ZVISIT"), ("ZTAG", "ZGROUP", "ZTAGGROUP"), ("Z_11VISITS_", "Z_18VISITS_", "ZVISIT"), ("Z_11VISITS_", "Z_11TAGS_5", "ZTAG"), ("Z_6TAGS_", "Z_6LOCATIONS_", "ZLOCATION"), ("Z_6TAGS_", "Z_11TAGS_2", "ZTAG"), ("Z_1TAGS_", "Z_1ACTIVITIES_", "ZACTIVITY"), ("Z_1TAGS_", "Z_11TAGS_", "ZTAG"), ] def extract(path): d = tempfile.mkdtemp(prefix="audit") with zipfile.ZipFile(path) as z: z.extractall(d) main = [os.path.join(r, f) for r, _, fs in os.walk(d) for f in fs if f.endswith(".sqlite")][0] con = sqlite3.connect(main) con.execute("PRAGMA wal_checkpoint(TRUNCATE)") con.commit() return con, d def cols(con, table): return [(r[1], r[2]) for r in con.execute('PRAGMA table_info("%s")' % table)] def blob_as_zeros(con, table, col): """BLOB 列里全 0x00 也算「空」——Core Data 的 UUID 不会是全 0。""" n = 0 for (v,) in con.execute('SELECT "%s" FROM "%s" WHERE "%s" IS NOT NULL' % (col, table, col)): if isinstance(v, bytes) and v and set(v) == {0}: n += 1 return n def profile(con, ref): """列 -> (NULL 数, 空串数, 全零 BLOB 数);ref=True 时额外统计行数基线""" out = {} for t in [r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")]: for c, ctype in cols(con, t): q = 'SELECT count(*), sum(CASE WHEN "%s" IS NULL THEN 1 ELSE 0 END), sum(CASE WHEN "%s" = \'\' THEN 1 ELSE 0 END) FROM "%s"' % (c, c, t) try: total, nulls, empties = con.execute(q).fetchone() except sqlite3.Error: continue nulls = nulls or 0 empties = empties or 0 z = blob_as_zeros(con, t, c) if "BLOB" in (ctype or "").upper() else 0 out[(t, c)] = (total, nulls, empties, z) return out def main(): ref_path, out_path = sys.argv[1], sys.argv[2] ref, d1 = extract(ref_path) out, d2 = extract(out_path) print("=" * 78) print("A. 逐列空值画像(参照 = %s)" % os.path.basename(ref_path)) print("=" * 78) pr = profile(ref, True) po = profile(out, False) bad = 0 for (t, c), (total_r, nulls_r, emp_r, zero_r) in sorted(pr.items()): if (t, c) not in po: print(" !! 导出缺列 %s.%s" % (t, c)) bad += 1 continue total_o, nulls_o, emp_o, zero_o = po[(t, c)] if nulls_r == 0 and nulls_o > 0: print(" !! %s.%s:参照里 0 个 NULL,导出里 %d 个 NULL(共 %d 行)" % (t, c, nulls_o, total_o)) bad += 1 if emp_r == 0 and emp_o > 0 and nulls_r == 0: print(" ~ %s.%s:参照里 0 个空串,导出里 %d 个空串" % (t, c, emp_o)) if zero_r == 0 and zero_o > 0: print(" !! %s.%s:参照里没有全零 BLOB,导出里 %d 个" % (t, c, zero_o)) bad += 1 if bad == 0: print(" 未发现「真机不留空、导出留空」的列 ✓") print() print("=" * 78) print("B. 关系完整性(悬空引用)") print("=" * 78) dangling = 0 for tbl, col, target in RELS: if tbl not in [r[0] for r in out.execute("SELECT name FROM sqlite_master WHERE type='table'")]: continue if col not in [c for c, _ in cols(out, tbl)]: continue q = ('SELECT count(*) FROM "%s" a WHERE a."%s" IS NOT NULL AND a."%s" <> 0 ' 'AND NOT EXISTS (SELECT 1 FROM "%s" b WHERE b.Z_PK = a."%s")') % (tbl, col, col, target, col) try: n = out.execute(q).fetchone()[0] except sqlite3.Error as e: print(" 跳过 %s.%s(%s)" % (tbl, col, e)) continue if n: print(" !! %s.%s -> %s:%d 条悬空" % (tbl, col, target, n)) for row in out.execute( 'SELECT a.Z_PK, a."%s" FROM "%s" a WHERE a."%s" IS NOT NULL AND a."%s" <> 0 ' 'AND NOT EXISTS (SELECT 1 FROM "%s" b WHERE b.Z_PK = a."%s") LIMIT 5' % (col, tbl, col, col, target, col)): print(" %s #%s -> %s" % (tbl, row[0], row[1])) dangling += n if dangling == 0: print(" 所有关系列悬空引用 0 ✓") print() print("=" * 78) print("C. Z_PRIMARYKEY.Z_MAX 是否够大(不够就会让 rond 下次插入撞键)") print("=" * 78) ents = {r[1]: (r[0], r[2]) for r in out.execute("SELECT Z_ENT, Z_NAME, Z_MAX FROM Z_PRIMARYKEY")} badmax = 0 for name, (ent, mx) in sorted(ents.items()): if not name or not out.execute("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", ("Z" + name,)).fetchone()[0]: continue tbl = "Z" + name try: real = out.execute('SELECT COALESCE(MAX(Z_PK),0) FROM "%s"' % tbl).fetchone()[0] except sqlite3.Error: continue if real > (mx or 0): print(" !! %s:Z_MAX=%s 但最大 Z_PK=%s" % (name, mx, real)) badmax += 1 if badmax == 0: print(" Z_MAX 全部 ≥ 实际最大 Z_PK ✓") print() print("结论:空值画像问题 %d 项,悬空引用 %d 条,Z_MAX 问题 %d 项" % (bad, dangling, badmax)) if __name__ == "__main__": main()