test_sql_dump.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. # -*- coding: utf-8 -*-
  2. """sql_dump 自有格式数据库备份单元测试(假连接,零真实数据库依赖)。
  3. 覆盖:语句切分、SQL 字面量渲染、外键拓扑排序、导出块结构(IDENTITY
  4. 包裹/主键行序/空表)、恢复执行序列(禁用外键→逆序清表→导入→恢复外键)、
  5. 空库与缺表守卫、外键启用失败与事务失败回补。
  6. """
  7. from __future__ import annotations
  8. import contextlib
  9. import re
  10. from datetime import date, datetime
  11. from decimal import Decimal
  12. import pytest
  13. from dms.common.errors import BackupFailedError
  14. from dms.services.sql_dump import (
  15. _build_dump_body,
  16. _foreign_key_edges,
  17. _render_value,
  18. _restore_via,
  19. _topological_order,
  20. split_statements,
  21. )
  22. # ------------------------------------------------------------- 假连接/假引擎
  23. class FakeResult:
  24. """最小结果集:keys() + 行迭代 + scalar_one。"""
  25. def __init__(self, columns, rows):
  26. self._columns = columns
  27. self._rows = rows
  28. def keys(self):
  29. return self._columns
  30. def __iter__(self):
  31. return iter(self._rows)
  32. def scalar_one(self):
  33. return self._rows[0][0]
  34. class FakeConnection:
  35. """按 SQL 前缀路由的假达梦连接,记录每一条下发的语句。"""
  36. def __init__(
  37. self,
  38. *,
  39. tables,
  40. rows_by_table=None,
  41. primary_keys=None,
  42. fk_edges=None,
  43. fk_constraints=None,
  44. identity_tables=(),
  45. fail_enable_constraints=(),
  46. ):
  47. self.tables = list(tables)
  48. self.rows_by_table = rows_by_table or {}
  49. self.primary_keys = primary_keys or {}
  50. self.fk_edges = list(fk_edges or [])
  51. self.fk_constraints = list(fk_constraints or [])
  52. self.identity_tables = set(identity_tables)
  53. self.fail_enable = set(fail_enable_constraints)
  54. self.executed = []
  55. def execute(self, sql, params=None):
  56. statement = getattr(sql, "text", str(sql))
  57. self.executed.append(statement)
  58. if statement.startswith("SELECT TABLE_NAME FROM ALL_TABLES"):
  59. return FakeResult(["TABLE_NAME"], [(t,) for t in self.tables])
  60. if statement.startswith("SELECT a.TABLE_NAME"):
  61. return FakeResult([], [(c, p) for c, p in self.fk_edges])
  62. if statement.startswith("SELECT TABLE_NAME, CONSTRAINT_NAME"):
  63. return FakeResult([], [(t, n) for t, n in self.fk_constraints])
  64. if statement.startswith("SELECT cols.COLUMN_NAME"):
  65. columns = self.primary_keys.get(params["t"], [])
  66. return FakeResult(["COLUMN_NAME"], [(c,) for c in columns])
  67. if match := re.match(r'SELECT COUNT\(\*\) FROM "[^"]+"\."([^"]+)"', statement):
  68. rows = self.rows_by_table.get(match.group(1), ([], []))[1]
  69. return FakeResult([], [(len(rows),)])
  70. if match := re.match(r'SELECT \* FROM "[^"]+"\."([^"]+)"', statement):
  71. columns, rows = self.rows_by_table.get(match.group(1), ([], []))
  72. return FakeResult(columns, rows)
  73. if match := re.match(
  74. r'SET IDENTITY_INSERT "[^"]+"\."([^"]+)" (ON|OFF)$', statement
  75. ):
  76. if match.group(1) not in self.identity_tables:
  77. raise RuntimeError("非 IDENTITY 表")
  78. return FakeResult([], [])
  79. if statement.startswith("DELETE FROM") or statement.startswith("INSERT INTO"):
  80. return FakeResult([], [])
  81. if match := re.match(
  82. r'ALTER TABLE "[^"]+"\."([^"]+)" (ENABLE|DISABLE) CONSTRAINT "([^"]+)"$',
  83. statement,
  84. ):
  85. _table, action, name = match.groups()
  86. if action == "ENABLE" and name in self.fail_enable:
  87. raise RuntimeError(f"约束 {name} 启用失败")
  88. return FakeResult([], [])
  89. raise AssertionError(f"未预期的 SQL:{statement}")
  90. class FakeTransaction:
  91. def __init__(self, log, fail_on=None):
  92. self.log = log
  93. self.fail_on = fail_on
  94. def execute(self, stmt):
  95. text = stmt.text
  96. if self.fail_on and self.fail_on in text:
  97. raise RuntimeError(f"事务内语句失败:{text}")
  98. self.log.append(text)
  99. class FakeEngine:
  100. def __init__(self, fail_on=None):
  101. self.executed = []
  102. self.fail_on = fail_on
  103. def begin(self):
  104. return contextlib.nullcontext(FakeTransaction(self.executed, self.fail_on))
  105. # ------------------------------------------------------------- 标准测试数据
  106. TABLES = ["DOC_CATEGORY", "SYS_ORGANIZATION", "SYS_USER"]
  107. ROWS = {
  108. "DOC_CATEGORY": (["ID", "NAME"], [(1, "规划"), (2, "其它")]),
  109. "SYS_ORGANIZATION": (["ID", "NAME"], [(1, "总部")]),
  110. "SYS_USER": (["ID", "NAME", "ORG_ID"], [(1, "管理员", 1)]),
  111. }
  112. PKS = {"DOC_CATEGORY": ["ID"], "SYS_ORGANIZATION": ["ID"], "SYS_USER": ["ID"]}
  113. FK_EDGES = [("SYS_USER", "SYS_ORGANIZATION"), ("SYS_USER", "DOC_CATEGORY")]
  114. FK_CONSTRAINTS = [("SYS_USER", "FK_USER_ORG"), ("SYS_USER", "FK_USER_CATEGORY")]
  115. IDENTITY = {"DOC_CATEGORY", "SYS_ORGANIZATION", "SYS_USER"}
  116. RESTORE_STATEMENTS = [
  117. 'SET IDENTITY_INSERT "DMS"."DOC_CATEGORY" ON',
  118. "INSERT INTO \"DMS\".\"DOC_CATEGORY\" (\"ID\", \"NAME\") VALUES (1, '规划')",
  119. 'SET IDENTITY_INSERT "DMS"."DOC_CATEGORY" OFF',
  120. "INSERT INTO \"DMS\".\"SYS_USER\" (\"ID\", \"NAME\", \"ORG_ID\") VALUES (1, '管理员', 1)",
  121. ]
  122. def _make_conn(**overrides):
  123. kwargs = dict(
  124. tables=TABLES,
  125. rows_by_table=ROWS,
  126. primary_keys=PKS,
  127. fk_edges=FK_EDGES,
  128. fk_constraints=FK_CONSTRAINTS,
  129. identity_tables=IDENTITY,
  130. )
  131. kwargs.update(overrides)
  132. return FakeConnection(**kwargs)
  133. # ------------------------------------------------------------- 脚本切分
  134. def test_split_statements_basic_and_empty():
  135. assert split_statements("A;\nB;") == ["A", "B"]
  136. assert split_statements("") == []
  137. assert split_statements("-- 仅注释; 无语句") == []
  138. assert split_statements("A;;; ;") == ["A"]
  139. assert split_statements("无分号结尾") == ["无分号结尾"]
  140. def test_split_statements_skips_comments():
  141. sql = "-- 头注释; 内含分号\nSET X ON; /* 块; 注释 */ DELETE FROM t; -- 尾注"
  142. assert split_statements(sql) == ["SET X ON", "DELETE FROM t"]
  143. def test_split_statements_keeps_quoted_semicolons_and_comments():
  144. sql = "INSERT INTO t VALUES ('a;b--c'); INSERT INTO \"T;X\" VALUES ('it''s;');"
  145. assert split_statements(sql) == [
  146. "INSERT INTO t VALUES ('a;b--c')",
  147. 'INSERT INTO "T;X" VALUES (\'it\'\'s;\')',
  148. ]
  149. # ------------------------------------------------------------- 字面量渲染
  150. def test_render_value_literals():
  151. assert _render_value(None) == "NULL"
  152. assert _render_value(True) == "1"
  153. assert _render_value(False) == "0"
  154. assert _render_value(42) == "42"
  155. assert _render_value(Decimal("1.25")) == "1.25"
  156. assert _render_value(1.5) == "1.5"
  157. assert _render_value(b"\x00\xff") == "0x00ff"
  158. assert _render_value(datetime(2026, 9, 9, 7, 8, 9, 123456)) == (
  159. "'2026-09-09 07:08:09.123456'"
  160. )
  161. assert _render_value(date(2026, 9, 9)) == "'2026-09-09'"
  162. assert _render_value("O'Neill") == "'O''Neill'"
  163. assert _render_value("规划") == "'规划'"
  164. # ------------------------------------------------------------- 拓扑排序
  165. def test_topological_order_parents_first_with_alpha_ties():
  166. # 边为 (父表, 子表):SYS_USER 引用另两张表,故排最后
  167. edges = [("SYS_ORGANIZATION", "SYS_USER"), ("DOC_CATEGORY", "SYS_USER")]
  168. tables = ["SYS_USER", "SYS_ORGANIZATION", "DOC_CATEGORY"]
  169. assert _topological_order(edges, tables) == [
  170. "DOC_CATEGORY",
  171. "SYS_ORGANIZATION",
  172. "SYS_USER",
  173. ]
  174. def test_topological_order_cycle_falls_back_to_alpha():
  175. edges = [("A", "B"), ("B", "A")]
  176. assert _topological_order(edges, ["B", "A", "C"]) == ["C", "A", "B"]
  177. def test_foreign_key_edges_parent_child_and_skip_self():
  178. conn = _make_conn(fk_edges=[("SYS_USER", "SYS_ORGANIZATION"), ("T", "T")])
  179. assert _foreign_key_edges(conn, "DMS") == [("SYS_ORGANIZATION", "SYS_USER")]
  180. # ------------------------------------------------------------- 导出
  181. def test_build_dump_body_structure_and_identity_wrap():
  182. body = _build_dump_body(_make_conn(), "DMS", "PYTHON")
  183. assert body.startswith("-- DMS 数据库备份(自有格式 v1)\n")
  184. assert "-- 生成引擎: PYTHON\n" in body
  185. assert "-- 源模式: DMS\n" in body
  186. assert "-- 表数量: 3\n" in body
  187. assert body.endswith("\n")
  188. lines = body.splitlines()
  189. inserts = [line for line in lines if line.startswith("INSERT INTO")]
  190. # 拓扑序(按各表首行出现位置):子表 SYS_USER 在两个父表之后
  191. first_index = {
  192. table: next(
  193. index
  194. for index, line in enumerate(inserts)
  195. if line.startswith(f'INSERT INTO "DMS"."{table}"')
  196. )
  197. for table in ("DOC_CATEGORY", "SYS_ORGANIZATION", "SYS_USER")
  198. }
  199. assert first_index["DOC_CATEGORY"] < first_index["SYS_USER"]
  200. assert first_index["SYS_ORGANIZATION"] < first_index["SYS_USER"]
  201. # 带列清单、字符串中文原文
  202. assert (
  203. "INSERT INTO \"DMS\".\"DOC_CATEGORY\" (\"ID\", \"NAME\") VALUES (1, '规划');"
  204. in inserts
  205. )
  206. # IDENTITY 表以 SET ON/OFF 包裹且包裹行夹住 INSERT
  207. on_line = 'SET IDENTITY_INSERT "DMS"."DOC_CATEGORY" ON;'
  208. off_line = 'SET IDENTITY_INSERT "DMS"."DOC_CATEGORY" OFF;'
  209. assert lines.index(on_line) < lines.index(inserts[0]) < lines.index(off_line)
  210. # 行数注释
  211. assert "-- 表 1/3: DOC_CATEGORY(2 行)" in lines
  212. assert "-- 表 3/3: SYS_USER(1 行)" in lines
  213. def test_build_dump_body_orders_rows_by_single_pk():
  214. conn = _make_conn()
  215. _build_dump_body(conn, "DMS", "PYTHON")
  216. assert 'SELECT * FROM "DMS"."DOC_CATEGORY" ORDER BY "ID"' in conn.executed
  217. assert 'SELECT * FROM "DMS"."SYS_USER" ORDER BY "ID"' in conn.executed
  218. def test_build_dump_body_composite_pk_skips_order_by():
  219. conn = _make_conn(primary_keys={"DOC_CATEGORY": ["ID"], "SYS_ORGANIZATION": ["ID"], "SYS_USER": ["ID", "NAME"]})
  220. _build_dump_body(conn, "DMS", "PYTHON")
  221. assert 'SELECT * FROM "DMS"."SYS_USER"' in conn.executed
  222. def test_build_dump_body_skips_identity_wrap_when_probe_fails():
  223. conn = _make_conn(identity_tables=set())
  224. body = _build_dump_body(conn, "DMS", "PYTHON")
  225. assert "SET IDENTITY_INSERT" not in body
  226. # 探测语句确实已下发:非 IDENTITY 表 ON 即报错,每表仅 1 条
  227. probes = [s for s in conn.executed if s.startswith("SET IDENTITY_INSERT")]
  228. assert len(probes) == 3
  229. def test_build_dump_body_empty_table_only_comment():
  230. rows = {
  231. "DOC_CATEGORY": (["ID", "NAME"], []),
  232. "SYS_ORGANIZATION": ROWS["SYS_ORGANIZATION"],
  233. "SYS_USER": ROWS["SYS_USER"],
  234. }
  235. body = _build_dump_body(_make_conn(rows_by_table=rows), "DMS", "JAVA")
  236. assert "-- 表 1/3: DOC_CATEGORY(0 行)" in body.splitlines()
  237. assert 'INSERT INTO "DMS"."DOC_CATEGORY"' not in body
  238. # 空表整块省略,IDENTITY 包裹也不出现
  239. assert 'SET IDENTITY_INSERT "DMS"."DOC_CATEGORY"' not in body
  240. def test_build_dump_body_empty_schema_rejected():
  241. with pytest.raises(BackupFailedError, match="没有任何表"):
  242. _build_dump_body(_make_conn(tables=[]), "DMS", "PYTHON")
  243. # ------------------------------------------------------------- 恢复
  244. def test_restore_via_disable_clear_import_enable_sequence():
  245. conn = _make_conn()
  246. engine = FakeEngine()
  247. _restore_via(conn, engine, "DMS", RESTORE_STATEMENTS, None)
  248. disables = [s for s in conn.executed if " DISABLE CONSTRAINT" in s]
  249. enables = [s for s in conn.executed if " ENABLE CONSTRAINT" in s]
  250. assert len(disables) == len(enables) == 2
  251. # 先全部禁用、业务执行后(finally)再启用
  252. assert conn.executed.index(disables[-1]) < conn.executed.index(enables[0])
  253. # 事务内:拓扑逆序清空全部业务表 → 按脚本顺序导入
  254. expected_clears = [
  255. 'DELETE FROM "DMS"."SYS_USER"',
  256. 'DELETE FROM "DMS"."SYS_ORGANIZATION"',
  257. 'DELETE FROM "DMS"."DOC_CATEGORY"',
  258. ]
  259. assert engine.executed == expected_clears + RESTORE_STATEMENTS
  260. def test_restore_via_empty_database_without_callback_rejected():
  261. conn = _make_conn(tables=[])
  262. with pytest.raises(BackupFailedError, match="目标库为空"):
  263. _restore_via(conn, FakeEngine(), "DMS", RESTORE_STATEMENTS, None)
  264. assert not [s for s in conn.executed if "CONSTRAINT" in s]
  265. def test_restore_via_empty_database_runs_schema_callback():
  266. conn = _make_conn(tables=[])
  267. engine = FakeEngine()
  268. calls = []
  269. def ensure_schema_ready():
  270. calls.append("upgrade")
  271. conn.tables = list(TABLES)
  272. _restore_via(conn, engine, "DMS", RESTORE_STATEMENTS, ensure_schema_ready)
  273. assert calls == ["upgrade"]
  274. assert engine.executed[-len(RESTORE_STATEMENTS):] == RESTORE_STATEMENTS
  275. def test_restore_via_missing_table_rejected_before_any_change():
  276. conn = _make_conn()
  277. engine = FakeEngine()
  278. statements = ['INSERT INTO "DMS"."NEW_TABLE" ("ID") VALUES (1)']
  279. with pytest.raises(BackupFailedError, match="目标库缺少备份中的表"):
  280. _restore_via(conn, engine, "DMS", statements, None)
  281. assert not [s for s in conn.executed if "CONSTRAINT" in s]
  282. assert engine.executed == []
  283. def test_restore_via_reports_failed_fk_enable():
  284. conn = _make_conn(fail_enable_constraints={"FK_USER_ORG"})
  285. engine = FakeEngine()
  286. statements = ["INSERT INTO \"DMS\".\"DOC_CATEGORY\" (\"ID\") VALUES (1)"]
  287. with pytest.raises(BackupFailedError, match="外键恢复失败"):
  288. _restore_via(conn, engine, "DMS", statements, None)
  289. enables = [s for s in conn.executed if " ENABLE CONSTRAINT" in s]
  290. assert len(enables) == 2
  291. assert any('CONSTRAINT "FK_USER_CATEGORY"' in s for s in enables)
  292. def test_restore_via_dml_failure_still_reenables_and_raises():
  293. conn = _make_conn()
  294. engine = FakeEngine(fail_on="BOOM")
  295. statements = [
  296. 'INSERT INTO "DMS"."DOC_CATEGORY" ("ID") VALUES (1)',
  297. 'INSERT INTO "DMS"."DOC_CATEGORY" ("ID") VALUES (2) /* BOOM */',
  298. ]
  299. with pytest.raises(RuntimeError, match="事务内语句失败"):
  300. _restore_via(conn, engine, "DMS", statements, None)
  301. # 失败前的语句已执行,失败语句未入库
  302. assert statements[0] in engine.executed
  303. assert statements[1] not in engine.executed
  304. # finally 仍完成外键恢复
  305. assert len([s for s in conn.executed if " DISABLE CONSTRAINT" in s]) == 2
  306. assert len([s for s in conn.executed if " ENABLE CONSTRAINT" in s]) == 2