compare_crm_sql_models.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from __future__ import annotations
  4. import argparse
  5. import dataclasses
  6. import html
  7. import re
  8. from pathlib import Path
  9. from typing import Dict, List, Tuple
  10. @dataclasses.dataclass(frozen=True)
  11. class Column:
  12. name: str
  13. raw_def: str # definition text after column name, normalized
  14. @dataclasses.dataclass(frozen=True)
  15. class Table:
  16. name: str
  17. columns: Dict[str, Column]
  18. CREATE_TABLE_RE = re.compile(r"^\s*CREATE\s+TABLE\s+`(?P<name>crm_[^`]+)`\s*\(\s*$", re.IGNORECASE)
  19. COL_RE = re.compile(r"^\s*`(?P<col>[^`]+)`\s+(?P<rest>.+)$")
  20. def _normalize_ws(s: str) -> str:
  21. s = s.strip()
  22. s = re.sub(r"\s+", " ", s)
  23. return s
  24. def parse_crm_tables(sql_text: str) -> Dict[str, Table]:
  25. """
  26. Parse Navicat-style MySQL DDL and extract CREATE TABLE blocks for crm_*.
  27. Only captures column lines beginning with backticked column names.
  28. """
  29. lines = sql_text.splitlines()
  30. tables: Dict[str, Table] = {}
  31. i = 0
  32. while i < len(lines):
  33. m = CREATE_TABLE_RE.match(lines[i])
  34. if not m:
  35. i += 1
  36. continue
  37. table_name = m.group("name")
  38. i += 1
  39. cols: Dict[str, Column] = {}
  40. while i < len(lines):
  41. line = lines[i].rstrip("\n")
  42. # End of CREATE TABLE block
  43. if re.search(r"^\s*\)\s*ENGINE=.*;\s*$", line, flags=re.IGNORECASE):
  44. break
  45. cm = COL_RE.match(line)
  46. if cm:
  47. col_name = cm.group("col").strip()
  48. rest = cm.group("rest").strip()
  49. # strip trailing comma
  50. if rest.endswith(","):
  51. rest = rest[:-1].rstrip()
  52. cols[col_name] = Column(name=col_name, raw_def=_normalize_ws(rest))
  53. i += 1
  54. tables[table_name] = Table(name=table_name, columns=cols)
  55. # move past ENGINE line if present
  56. while i < len(lines) and not lines[i].strip().endswith(";"):
  57. i += 1
  58. if i < len(lines):
  59. i += 1
  60. return tables
  61. def diff_tables(
  62. left: Dict[str, Table],
  63. right: Dict[str, Table],
  64. ) -> Tuple[List[str], List[str], Dict[str, Dict[str, Tuple[str | None, str | None]]]]:
  65. """
  66. Returns:
  67. - left_only_tables
  68. - right_only_tables
  69. - per_table_column_diffs: {table: {col: (left_def, right_def)}}
  70. where any of left_def/right_def can be None (missing), or both present but different.
  71. """
  72. left_only = sorted(set(left.keys()) - set(right.keys()))
  73. right_only = sorted(set(right.keys()) - set(left.keys()))
  74. common = sorted(set(left.keys()) & set(right.keys()))
  75. per_table: Dict[str, Dict[str, Tuple[str | None, str | None]]] = {}
  76. for t in common:
  77. lcols = left[t].columns
  78. rcols = right[t].columns
  79. all_cols = sorted(set(lcols.keys()) | set(rcols.keys()))
  80. diffs: Dict[str, Tuple[str | None, str | None]] = {}
  81. for c in all_cols:
  82. ldef = lcols.get(c).raw_def if c in lcols else None
  83. rdef = rcols.get(c).raw_def if c in rcols else None
  84. if ldef != rdef:
  85. diffs[c] = (ldef, rdef)
  86. if diffs:
  87. per_table[t] = diffs
  88. return left_only, right_only, per_table
  89. def _h(s: str) -> str:
  90. return html.escape(s, quote=True)
  91. def render_html_report(
  92. left_name: str,
  93. right_name: str,
  94. left_only_tables: List[str],
  95. right_only_tables: List[str],
  96. per_table_column_diffs: Dict[str, Dict[str, Tuple[str | None, str | None]]],
  97. ) -> str:
  98. diff_table_count = len(per_table_column_diffs)
  99. diff_col_count = sum(len(v) for v in per_table_column_diffs.values())
  100. parts: List[str] = []
  101. parts.append("<!doctype html>")
  102. parts.append('<html lang="zh-CN">')
  103. parts.append("<head>")
  104. parts.append('<meta charset="utf-8" />')
  105. parts.append('<meta name="viewport" content="width=device-width,initial-scale=1" />')
  106. parts.append("<title>CRM 表结构差异报告</title>")
  107. parts.append(
  108. """
  109. <style>
  110. :root{
  111. --bg:#0b1220;
  112. --panel:#0f1b2d;
  113. --panel2:#0c1626;
  114. --text:#e5e7eb;
  115. --muted:#94a3b8;
  116. --border:#1f2a3b;
  117. --danger:#ef4444;
  118. --danger-bg:rgba(239,68,68,.12);
  119. --ok:#10b981;
  120. }
  121. body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
  122. .wrap{max-width:1200px;margin:0 auto;padding:24px}
  123. .header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:16px}
  124. .title{font-size:20px;font-weight:700}
  125. .sub{color:var(--muted);font-size:12px}
  126. .card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--border);border-radius:12px;padding:16px;margin:12px 0}
  127. .grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
  128. @media (max-width: 900px){.grid{grid-template-columns:1fr}}
  129. .kpi{display:flex;gap:12px;flex-wrap:wrap}
  130. .kpi .item{background:rgba(255,255,255,.03);border:1px solid var(--border);border-radius:10px;padding:10px 12px}
  131. .kpi .num{font-weight:800;font-size:18px}
  132. .kpi .lab{color:var(--muted);font-size:12px;margin-top:2px}
  133. h2{margin:0 0 10px 0;font-size:16px}
  134. ul{margin:0;padding-left:18px}
  135. a{color:#60a5fa;text-decoration:none}
  136. a:hover{text-decoration:underline}
  137. table{width:100%;border-collapse:collapse;border:1px solid var(--border);border-radius:10px;overflow:hidden}
  138. th,td{padding:10px 10px;border-bottom:1px solid var(--border);vertical-align:top}
  139. th{background:rgba(255,255,255,.04);text-align:left;color:#cbd5e1;font-size:12px;letter-spacing:.02em}
  140. tr:last-child td{border-bottom:none}
  141. .diff-row td{background:var(--danger-bg)}
  142. .diff{color:var(--danger);font-weight:700}
  143. .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;white-space:pre-wrap;word-break:break-word}
  144. .pill{display:inline-block;padding:2px 8px;border-radius:999px;border:1px solid var(--border);color:var(--muted);font-size:12px}
  145. .toplinks{display:flex;gap:10px;flex-wrap:wrap}
  146. </style>
  147. """.strip()
  148. )
  149. parts.append("</head>")
  150. parts.append("<body>")
  151. parts.append('<div class="wrap">')
  152. parts.append('<div class="header">')
  153. parts.append('<div>')
  154. parts.append('<div class="title">CRM 表结构差异报告</div>')
  155. parts.append(
  156. f'<div class="sub">仅比较 <span class="pill">crm_*</span> 表的字段定义(类型/长度、NULL、DEFAULT、COMMENT 等属性)。</div>'
  157. )
  158. parts.append("</div>")
  159. parts.append(
  160. f'<div class="sub">{_h(left_name)} vs {_h(right_name)}</div>'
  161. )
  162. parts.append("</div>")
  163. parts.append('<div class="card kpi">')
  164. parts.append(
  165. f'<div class="item"><div class="num">{len(left_only_tables)}</div><div class="lab">{_h(left_name)} 独有表</div></div>'
  166. )
  167. parts.append(
  168. f'<div class="item"><div class="num">{len(right_only_tables)}</div><div class="lab">{_h(right_name)} 独有表</div></div>'
  169. )
  170. parts.append(
  171. f'<div class="item"><div class="num">{diff_table_count}</div><div class="lab">同名但字段有差异的表</div></div>'
  172. )
  173. parts.append(
  174. f'<div class="item"><div class="num">{diff_col_count}</div><div class="lab">字段差异条目</div></div>'
  175. )
  176. parts.append("</div>")
  177. parts.append('<div class="card grid">')
  178. parts.append("<div>")
  179. parts.append(f"<h2>{_h(left_name)} 比 {_h(right_name)} 多的表</h2>")
  180. if left_only_tables:
  181. parts.append("<ul>")
  182. for t in left_only_tables:
  183. parts.append(f"<li class='mono'>{_h(t)}</li>")
  184. parts.append("</ul>")
  185. else:
  186. parts.append(f"<div class='sub'>无</div>")
  187. parts.append("</div>")
  188. parts.append("<div>")
  189. parts.append(f"<h2>{_h(right_name)} 比 {_h(left_name)} 多的表</h2>")
  190. if right_only_tables:
  191. parts.append("<ul>")
  192. for t in right_only_tables:
  193. parts.append(f"<li class='mono'>{_h(t)}</li>")
  194. parts.append("</ul>")
  195. else:
  196. parts.append(f"<div class='sub'>无</div>")
  197. parts.append("</div>")
  198. parts.append("</div>")
  199. if per_table_column_diffs:
  200. parts.append('<div class="card">')
  201. parts.append("<h2>同名表字段差异(红色为差异)</h2>")
  202. parts.append('<div class="toplinks sub">')
  203. for t in sorted(per_table_column_diffs.keys()):
  204. parts.append(f"<a href='#{_h(t)}' class='mono'>{_h(t)}</a>")
  205. parts.append("</div>")
  206. parts.append("</div>")
  207. for t in sorted(per_table_column_diffs.keys()):
  208. diffs = per_table_column_diffs[t]
  209. parts.append(f"<div class='card' id='{_h(t)}'>")
  210. parts.append(f"<h2 class='mono'>{_h(t)}</h2>")
  211. parts.append("<table>")
  212. parts.append("<thead>")
  213. parts.append(
  214. f"<tr><th style='width:220px'>字段</th><th>{_h(left_name)}</th><th>{_h(right_name)}</th></tr>"
  215. )
  216. parts.append("</thead>")
  217. parts.append("<tbody>")
  218. for col in sorted(diffs.keys()):
  219. ldef, rdef = diffs[col]
  220. ltxt = ldef if ldef is not None else "(缺失)"
  221. rtxt = rdef if rdef is not None else "(缺失)"
  222. parts.append("<tr class='diff-row'>")
  223. parts.append(f"<td class='mono diff'>{_h(col)}</td>")
  224. parts.append(f"<td class='mono diff'>{_h(ltxt)}</td>")
  225. parts.append(f"<td class='mono diff'>{_h(rtxt)}</td>")
  226. parts.append("</tr>")
  227. parts.append("</tbody>")
  228. parts.append("</table>")
  229. parts.append("</div>")
  230. parts.append("</div>")
  231. parts.append("</body>")
  232. parts.append("</html>")
  233. return "\n".join(parts)
  234. def main() -> int:
  235. p = argparse.ArgumentParser(description="Compare crm_* tables between two SQL dumps and output an HTML report.")
  236. p.add_argument("--left", required=True, help="Left SQL file path (e.g. shanxia2.0.sql)")
  237. p.add_argument("--right", required=True, help="Right SQL file path (e.g. sx.sql)")
  238. p.add_argument("--out", required=True, help="Output HTML path")
  239. p.add_argument("--left-name", default="shanxia2.0", help="Label for left side")
  240. p.add_argument("--right-name", default="crm(sx)", help="Label for right side")
  241. args = p.parse_args()
  242. left_text = Path(args.left).read_text(encoding="utf-8", errors="ignore")
  243. right_text = Path(args.right).read_text(encoding="utf-8", errors="ignore")
  244. left_tables = parse_crm_tables(left_text)
  245. right_tables = parse_crm_tables(right_text)
  246. left_only, right_only, per_table = diff_tables(left_tables, right_tables)
  247. report = render_html_report(args.left_name, args.right_name, left_only, right_only, per_table)
  248. out_path = Path(args.out)
  249. out_path.parent.mkdir(parents=True, exist_ok=True)
  250. out_path.write_text(report, encoding="utf-8")
  251. print(f"Parsed tables: left={len(left_tables)} right={len(right_tables)}")
  252. print(f"Left-only tables: {len(left_only)} Right-only tables: {len(right_only)}")
  253. print(f"Tables with column diffs: {len(per_table)}; diff items: {sum(len(v) for v in per_table.values())}")
  254. print(f"Wrote HTML: {out_path}")
  255. return 0
  256. if __name__ == "__main__":
  257. raise SystemExit(main())