sql_dump.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. # -*- coding: utf-8 -*-
  2. """自有格式数据库备份:数据 SQL 脚本导出/导入(替代 dexp/dimp,零外部工具)。
  3. 格式(database/dms-data.sql,UTF-8 文本)::
  4. -- DMS 数据库备份(自有格式 v1)
  5. -- 生成引擎: PYTHON / JAVA
  6. -- 生成时间: 2026-09-09 12:00:00
  7. -- 源模式: DMS
  8. -- 表数量: 14
  9. SET IDENTITY_INSERT "DMS"."DOC_CATEGORY" ON;
  10. INSERT INTO "DMS"."DOC_CATEGORY" ("ID", "NAME") VALUES (1, '规划');
  11. SET IDENTITY_INSERT "DMS"."DOC_CATEGORY" OFF;
  12. 设计要点:
  13. - 表按外键依赖拓扑序(父表在前,字母序稳定),行按单列数值主键升序——
  14. 同库同数据多次导出逐字节一致;自引用表父行 ID 必小于子行,天然满足;
  15. - 业务表与迁移簿记表(alembic_version / flyway_schema_history)一并导出,
  16. 版本戳随备份走,恢复后启动校验语义与 dexp 时代一致;
  17. - IDENTITY 表以 SET IDENTITY_INSERT 包裹;包裹与否由导出时对目标库探测
  18. 决定(SET ON 后立即 OFF,成功即含 IDENTITY 列),不猜测系统视图;
  19. - 字面量:NULL / 数值原文(驱动字符串化保真)/ 二进制 0x hex / 日期时间
  20. 'YYYY-MM-DD HH:MM:SS.ffffff' / 其余单引号转义;
  21. - 恢复:禁用全部外键 → 清空全部业务表 → 事务内执行脚本 → 恢复(启用)
  22. 全部外键——启用即完整性校验。外键维护是 DDL,在达梦隐式提交,置于
  23. 业务事务之外;业务事务失败自动回滚后仍会重试恢复外键。
  24. Java 版 com.dms.backup.SqlDataDumper 与本模块逐条对齐(同一 SQL、同一
  25. 格式、同一恢复语义),两侧产物可互相导入。
  26. """
  27. from __future__ import annotations
  28. import re
  29. from datetime import date, datetime
  30. from decimal import Decimal
  31. from pathlib import Path
  32. from typing import Any, Callable
  33. from dms.common.errors import BackupFailedError
  34. DUMP_FILE_NAME = "dms-data.sql"
  35. _INSERT_TABLE_RE = re.compile(
  36. r'^INSERT\s+INTO\s+"([^"]+)"\."([^"]+)"', re.IGNORECASE
  37. )
  38. # ==================================================================== 导出
  39. def export_dump(database_url: str, output_directory: Path, schema: str) -> Path:
  40. """全量导出 schema 业务数据到 output_directory/dms-data.sql,返回脚本路径。"""
  41. import sqlalchemy as sa
  42. output_directory.mkdir(parents=True, exist_ok=True)
  43. dump_file = output_directory / DUMP_FILE_NAME
  44. engine = sa.create_engine(database_url)
  45. try:
  46. with engine.connect() as conn:
  47. body = _build_dump_body(conn, schema, "PYTHON")
  48. finally:
  49. engine.dispose()
  50. dump_file.write_text(body, encoding="utf-8", newline="\n")
  51. return dump_file
  52. def _build_dump_body(conn: Any, schema: str, engine_label: str) -> str:
  53. """导出主体(吃已连接的 conn,便于双端一致性测试)。"""
  54. tables = _list_tables(conn, schema)
  55. if not tables:
  56. raise BackupFailedError(f"数据库模式 {schema} 中没有任何表,无法导出")
  57. order = _topological_order(_foreign_key_edges(conn, schema), tables)
  58. blocks: list[str] = [
  59. "-- DMS 数据库备份(自有格式 v1)",
  60. f"-- 生成引擎: {engine_label}",
  61. f"-- 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
  62. f"-- 源模式: {schema}",
  63. f"-- 表数量: {len(order)}",
  64. "",
  65. ]
  66. for index, table in enumerate(order, start=1):
  67. blocks.append(
  68. f"-- 表 {index}/{len(order)}: {table}"
  69. f"({_row_count(conn, schema, table)} 行)"
  70. )
  71. blocks.extend(_table_blocks(conn, schema, table))
  72. return "\n".join(blocks).rstrip("\n") + "\n"
  73. def _list_tables(conn: Any, schema: str) -> list[str]:
  74. """schema 下全部表名字典序(ALL_TABLES 不含视图;DM Oracle 兼容字典)。"""
  75. from sqlalchemy import text
  76. rows = conn.execute(
  77. text("SELECT TABLE_NAME FROM ALL_TABLES WHERE OWNER = :o ORDER BY TABLE_NAME"),
  78. {"o": schema},
  79. )
  80. return [str(row[0]).upper() for row in rows]
  81. def _foreign_key_edges(conn: Any, schema: str) -> list[tuple[str, str]]:
  82. """外键依赖边 (父表, 子表);自引用与跨 schema 引用忽略。"""
  83. from sqlalchemy import text
  84. rows = conn.execute(
  85. text(
  86. "SELECT a.TABLE_NAME AS child_table, c.TABLE_NAME AS parent_table"
  87. " FROM ALL_CONSTRAINTS a"
  88. " JOIN ALL_CONSTRAINTS c ON a.R_CONSTRAINT_NAME = c.CONSTRAINT_NAME"
  89. " WHERE a.CONSTRAINT_TYPE = 'R' AND a.OWNER = :o"
  90. ),
  91. {"o": schema},
  92. )
  93. edges = []
  94. for child, parent in rows:
  95. child_table, parent_table = str(child).upper(), str(parent).upper()
  96. if child_table != parent_table:
  97. edges.append((parent_table, child_table))
  98. return edges
  99. def _topological_order(
  100. edges: list[tuple[str, str]], tables: list[str]
  101. ) -> list[str]:
  102. """Kahn 拓扑排序(父表在前),同层字母序保证确定性;环内剩余表字母序追加。"""
  103. remaining = {
  104. table: {parent for parent, child in edges if child == table}
  105. for table in tables
  106. }
  107. ordered: list[str] = []
  108. while remaining:
  109. ready = sorted(t for t, parents in remaining.items() if not parents)
  110. if not ready: # 依赖环:DISABLE 外键后导入顺序无关,字母序收尾
  111. ordered.extend(sorted(remaining))
  112. break
  113. for table in ready:
  114. ordered.append(table)
  115. remaining.pop(table)
  116. for table in remaining:
  117. remaining[table] -= set(ordered)
  118. return ordered
  119. def _primary_key(conn: Any, schema: str, table: str) -> list[str]:
  120. """主键列名(按键序);无主键返回空。"""
  121. from sqlalchemy import text
  122. rows = conn.execute(
  123. text(
  124. "SELECT cols.COLUMN_NAME"
  125. " FROM ALL_CONSTRAINTS con"
  126. " JOIN ALL_CONS_COLUMNS cols"
  127. " ON con.CONSTRAINT_NAME = cols.CONSTRAINT_NAME"
  128. " AND con.OWNER = cols.OWNER"
  129. " WHERE con.CONSTRAINT_TYPE = 'P' AND con.OWNER = :o"
  130. " AND con.TABLE_NAME = :t"
  131. " ORDER BY cols.POSITION"
  132. ),
  133. {"o": schema, "t": table},
  134. )
  135. return [str(row[0]) for row in rows]
  136. def _row_count(conn: Any, schema: str, table: str) -> int:
  137. from sqlalchemy import text
  138. return int(
  139. conn.execute(
  140. text(f'SELECT COUNT(*) FROM "{schema}"."{table}"')
  141. ).scalar_one()
  142. )
  143. def _table_blocks(conn: Any, schema: str, table: str) -> list[str]:
  144. """单表导出块:IDENTITY 包裹(探测)+ 逐行 INSERT(带列清单)。"""
  145. from sqlalchemy import text
  146. target = f'"{schema}"."{table}"'
  147. identity = _table_has_identity(conn, target)
  148. key_columns = _primary_key(conn, schema, table)
  149. if len(key_columns) == 1:
  150. select = f"SELECT * FROM {target} ORDER BY \"{key_columns[0]}\""
  151. else:
  152. select = f"SELECT * FROM {target}"
  153. result = conn.execute(text(select))
  154. columns = [str(name).upper() for name in result.keys()]
  155. column_list = ", ".join(f'"{name}"' for name in columns)
  156. rows = [
  157. "INSERT INTO {target} ({columns}) VALUES ({values});".format(
  158. target=target,
  159. columns=column_list,
  160. values=", ".join(_render_value(value) for value in row),
  161. )
  162. for row in result
  163. ]
  164. if not rows:
  165. return []
  166. if not identity:
  167. return rows
  168. return [f"SET IDENTITY_INSERT {target} ON;", *rows, f"SET IDENTITY_INSERT {target} OFF;"]
  169. def _table_has_identity(conn: Any, target: str) -> bool:
  170. """探测表是否含 IDENTITY 列:SET ON 后立即 OFF 成功即为真。"""
  171. from sqlalchemy import text
  172. try:
  173. conn.execute(text(f"SET IDENTITY_INSERT {target} ON"))
  174. conn.execute(text(f"SET IDENTITY_INSERT {target} OFF"))
  175. return True
  176. except Exception: # noqa: BLE001 - 非 IDENTITY 表达梦直接报错,按无处理
  177. return False
  178. def _render_value(value: Any) -> str:
  179. """SQL 字面量渲染(与 Java SqlDataDumper.renderValue 语义对齐)。"""
  180. if value is None:
  181. return "NULL"
  182. if isinstance(value, bool):
  183. return "1" if value else "0"
  184. if isinstance(value, (int, Decimal)):
  185. return str(value)
  186. if isinstance(value, float):
  187. return repr(value)
  188. if isinstance(value, (bytes, bytearray, memoryview)):
  189. return "0x" + bytes(value).hex()
  190. if isinstance(value, datetime):
  191. return "'" + value.strftime("%Y-%m-%d %H:%M:%S.%f") + "'"
  192. if isinstance(value, date):
  193. return "'" + value.strftime("%Y-%m-%d") + "'"
  194. return "'" + str(value).replace("'", "''") + "'"
  195. # ==================================================================== 恢复
  196. def restore_dump(
  197. database_url: str,
  198. dump_file: Path,
  199. schema: str,
  200. ensure_schema_ready: Callable[[], None] | None = None,
  201. ) -> None:
  202. """导入自有格式数据脚本:禁用外键 → 清表 → 事务导入 → 恢复外键。
  203. ensure_schema_ready:目标库为空表时的建库回调(Python CLI 传 Alembic
  204. upgrade head;为 None 且空库则报错引导)。
  205. """
  206. import sqlalchemy as sa
  207. statements = split_statements(dump_file.read_text(encoding="utf-8"))
  208. if not statements:
  209. raise BackupFailedError(f"数据库脚本为空或不含任何语句:{dump_file}")
  210. engine = sa.create_engine(database_url)
  211. try:
  212. with engine.connect() as conn:
  213. _restore_via(conn, engine, schema, statements, ensure_schema_ready)
  214. finally:
  215. engine.dispose()
  216. def _restore_via(
  217. conn: Any,
  218. engine: Any,
  219. schema: str,
  220. statements: list[str],
  221. ensure_schema_ready: Callable[[], None] | None,
  222. ) -> None:
  223. """恢复主体(吃已连接的 conn 与 engine,便于双端一致性测试)。"""
  224. from sqlalchemy import text
  225. tables = _list_tables(conn, schema)
  226. if not tables:
  227. if ensure_schema_ready is None:
  228. raise BackupFailedError(
  229. "目标库为空(无任何表)。请先完成数据库初始化"
  230. "(upgrade-db 或参照部署文档建库)后再执行恢复。"
  231. )
  232. ensure_schema_ready()
  233. tables = _list_tables(conn, schema)
  234. dump_tables = sorted(
  235. {
  236. f"{match.group(1)}.{match.group(2)}".upper()
  237. for statement in statements
  238. if (match := _INSERT_TABLE_RE.match(statement))
  239. }
  240. )
  241. known = set(tables)
  242. missing = [name for name in dump_tables if name.split(".", 1)[1] not in known]
  243. if missing:
  244. raise BackupFailedError(
  245. f"目标库缺少备份中的表:{', '.join(missing)}。"
  246. "请将程序升级到与备份匹配的版本后重试。"
  247. )
  248. # 外键禁用(DDL 隐式提交;个别失败不阻断,导入顺序已按拓扑序兜底)
  249. disabled = _set_foreign_keys(conn, schema, False)
  250. try:
  251. with engine.begin() as transaction:
  252. for table in reversed(tables):
  253. transaction.execute(text(f'DELETE FROM "{schema}"."{table}"'))
  254. for statement in statements:
  255. transaction.execute(text(statement))
  256. finally:
  257. # 成功:启用即完整性校验;失败:回滚后恢复原状,启用应通过
  258. failed = _set_foreign_keys(conn, schema, True, only=disabled)
  259. if failed:
  260. detail = ";".join(f'"{schema}"."{table}" 约束 "{name}"' for table, name in failed)
  261. raise BackupFailedError(f"外键恢复失败(数据完整性校验未通过):{detail}")
  262. def _set_foreign_keys(
  263. conn: Any,
  264. schema: str,
  265. enable: bool,
  266. only: list[tuple[str, str]] | None = None,
  267. ) -> list[tuple[str, str]]:
  268. """批量启用/禁用外键,返回成功操作的约束清单(enable 失败返回失败项语义不同)。
  269. disable 返回成功禁用的清单;enable(only 指定目标)返回失败清单。
  270. """
  271. from sqlalchemy import text
  272. if only is None:
  273. rows = conn.execute(
  274. text(
  275. "SELECT TABLE_NAME, CONSTRAINT_NAME FROM ALL_CONSTRAINTS"
  276. " WHERE CONSTRAINT_TYPE = 'R' AND OWNER = :o"
  277. " ORDER BY TABLE_NAME, CONSTRAINT_NAME"
  278. ),
  279. {"o": schema},
  280. )
  281. constraints = [(str(row[0]).upper(), str(row[1])) for row in rows]
  282. else:
  283. constraints = only
  284. action = "ENABLE" if enable else "DISABLE"
  285. succeeded: list[tuple[str, str]] = []
  286. failed: list[tuple[str, str]] = []
  287. for table, name in constraints:
  288. try:
  289. conn.execute(
  290. text(f'ALTER TABLE "{schema}"."{table}" {action} CONSTRAINT "{name}"')
  291. )
  292. succeeded.append((table, name))
  293. except Exception: # noqa: BLE001 - 单条失败继续,禁用失败靠拓扑序兜底
  294. failed.append((table, name))
  295. return failed if enable else succeeded
  296. # ==================================================================== 脚本切分
  297. def split_statements(sql: str) -> list[str]:
  298. """按分号切分;跳过行/块注释;保留单引号字符串与双引号标识符(含转义)。"""
  299. out: list[str] = []
  300. buf: list[str] = []
  301. i, n = 0, len(sql)
  302. while i < n:
  303. char = sql[i]
  304. nxt = sql[i + 1] if i + 1 < n else ""
  305. if char == "-" and nxt == "-":
  306. while i < n and sql[i] != "\n":
  307. i += 1
  308. continue
  309. if char == "/" and nxt == "*":
  310. i += 2
  311. while i + 1 < n and not (sql[i] == "*" and sql[i + 1] == "/"):
  312. i += 1
  313. i += 2
  314. continue
  315. if char == "'":
  316. buf.append(char)
  317. i += 1
  318. while i < n:
  319. buf.append(sql[i])
  320. if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
  321. buf.append("'")
  322. i += 2
  323. continue
  324. if sql[i] == "'":
  325. break
  326. i += 1
  327. i += 1
  328. continue
  329. if char == '"':
  330. buf.append(char)
  331. i += 1
  332. while i < n:
  333. buf.append(sql[i])
  334. if sql[i] == '"' and i + 1 < n and sql[i + 1] == '"':
  335. buf.append('"')
  336. i += 2
  337. continue
  338. if sql[i] == '"':
  339. break
  340. i += 1
  341. i += 1 # 跳过结束引号(与单引号分支对齐,缺失会重入字符串状态)
  342. continue
  343. if char == ";":
  344. statement = "".join(buf).strip()
  345. if statement:
  346. out.append(statement)
  347. buf = []
  348. i += 1
  349. continue
  350. buf.append(char)
  351. i += 1
  352. tail = "".join(buf).strip()
  353. if tail:
  354. out.append(tail)
  355. return out