#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 从老仓库 zteits-clouds 的 mapper XML + 实体注解里静态抽取「表清单」。 为什么需要它:老库是**生产库**,P2 之前不能连;而 `doc/sql/*.sql` 只是 ALTER 变更日志, 不含完整建表语句。所以合并六库前,唯一可离线获得的全量表清单来源是**代码里真正用到的表名** (XML 的 FROM/JOIN/INTO/UPDATE + tk.mybatis 实体的 @Table)。 产出: table-inventory.csv 表名, 所属模块(可能有多个), 引用文件数, 来源(xml/entity), 是否跨模块 table-inventory.md 人读的汇总:冲突表、无前缀表、跨模块访问表 用法: python3 extract_table_inventory.py <老仓库根目录> <输出目录> """ import csv import os import re import sys from collections import defaultdict # SQL 关键字:这些词长得像表名但不能当表名 SQL_KEYWORDS = { 'select', 'from', 'where', 'join', 'inner', 'left', 'right', 'outer', 'full', 'on', 'as', 'and', 'or', 'set', 'values', 'into', 'update', 'delete', 'insert', 'group', 'order', 'by', 'having', 'limit', 'union', 'all', 'distinct', 'dual', 'case', 'when', 'then', 'else', 'end', 'if', 'exists', 'not', 'in', 'is', 'null', 'count', 'sum', 'max', 'min', 'avg', 'asc', 'desc', 'table', 'cross', 'using', } # 抓 from/join/into/update/delete from 后面的表名(允许 `db`.`table` 或 db.table) # ⚠️ 字符类里**不能**含 `$`:老代码有 `FROM pay_order${tableName}` 这种按后缀分表的写法, # 含 `$` 会把它当成表名 `pay_order$`,凭空多出两个不存在的表。 TABLE_REF = re.compile( r'\b(?:from|join|into|update)\s+' r'(?:`?([A-Za-z_]\w*)`?\s*\.\s*)?' # 可选 schema 前缀 r'`?([A-Za-z_]\w*)`?', re.IGNORECASE, ) TABLE_ANNO = re.compile(r'@Table\s*\(\s*name\s*=\s*"([^"]+)"') # 动态表名:`FROM tp_p_eqp_inout_record${tableName}` / `from pay_order${year}` # 这类表**静态抽不出真实名字**(后缀是调用方在运行时拼的,如 yyyyMM),必须单独列出来 DYNAMIC_TABLE = re.compile( r'\b(?:from|join|into|update)\s+([A-Za-z_]\w*)\s*\$\{(\w+)\}', re.IGNORECASE, ) TABLENAME_ANNO = re.compile(r'@TableName\s*\(\s*(?:value\s*=\s*)?"([^"]+)"') def module_of(rel_path): """取相对路径的第一段作为模块名,如 zteits-park/src/... -> zteits-park""" parts = rel_path.replace(os.sep, '/').split('/') return parts[0] if parts else '(root)' def scan(root): tables = defaultdict(lambda: { 'modules': set(), 'files': set(), 'kinds': set(), 'schemas': set(), }) # (表名前缀, 变量名) -> {模块} dynamic = {} def record(table, module, rel_path, kind, schema=None): table = table.lower() if table in SQL_KEYWORDS or len(table) < 2: return if table.startswith('$') or table.startswith('{'): return entry = tables[table] entry['modules'].add(module) entry['files'].add(rel_path) entry['kinds'].add(kind) if schema: entry['schemas'].add(schema.lower()) for dirpath, dirnames, filenames in os.walk(root): # 跳过构建产物与前端依赖 dirnames[:] = [ d for d in dirnames if d not in ('node_modules', 'target', '.git', 'dist', '.idea') ] for fn in filenames: full = os.path.join(dirpath, fn) rel = os.path.relpath(full, root) module = module_of(rel) if fn.endswith('.xml') and ('mapper' in rel or 'smapper' in rel): try: with open(full, encoding='utf-8', errors='ignore') as fh: text = fh.read() except OSError: continue if '', ' ', text, flags=re.DOTALL) for m in TABLE_REF.finditer(text): # ⚠️ `` / `` 这类**标签属性**也会命中 # (标签名恰好是 update/insert,属性名恰好是 id),全仓 1613 处。 # 属性后面紧跟 `=`,真实的表名不会,据此排掉。 tail = text[m.end():m.end() + 8].lstrip() if tail.startswith('='): continue schema, table = m.group(1), m.group(2) record(table, module, rel, 'xml', schema) for m in DYNAMIC_TABLE.finditer(text): dynamic.setdefault((m.group(1).lower(), m.group(2)), set()).add(module) elif fn.endswith('.java'): try: with open(full, encoding='utf-8', errors='ignore') as fh: text = fh.read() except OSError: continue for rx in (TABLE_ANNO, TABLENAME_ANNO): for m in rx.finditer(text): record(m.group(1), module, rel, 'entity') return tables, dynamic def main(): if len(sys.argv) != 3: print(__doc__) return 2 root, outdir = sys.argv[1], sys.argv[2] if not os.path.isdir(root): print('不是目录: %s' % root) return 2 os.makedirs(outdir, exist_ok=True) tables, dynamic = scan(root) print('抽出表名 %d 个' % len(tables)) print('动态表名(运行时拼后缀)%d 族' % len(dynamic)) csv_path = os.path.join(outdir, 'table-inventory.csv') with open(csv_path, 'w', newline='', encoding='utf-8') as fh: w = csv.writer(fh) w.writerow(['表名', '模块', '模块数', '引用文件数', '来源', 'schema', '跨模块']) for name in sorted(tables): e = tables[name] mods = sorted(e['modules']) w.writerow([ name, '|'.join(mods), len(mods), len(e['files']), '|'.join(sorted(e['kinds'])), '|'.join(sorted(e['schemas'])), 'Y' if len(mods) > 1 else '', ]) print('写出 %s' % csv_path) md_path = os.path.join(outdir, 'table-inventory.md') with open(md_path, 'w', encoding='utf-8') as fh: write_report(fh, tables, root, dynamic) print('写出 %s' % md_path) return 0 def write_report(fh, tables, root, dynamic=None): w = fh.write # 模块 → 单模块归属的表 owned = defaultdict(list) cross = [] for name in sorted(tables): mods = sorted(tables[name]['modules']) if len(mods) == 1: owned[mods[0]].append(name) else: cross.append((name, mods)) # 前缀 → 表 prefix = defaultdict(list) for name in tables: prefix[name.split('_')[0]].append(name) w('# 表清单(静态抽取自 `%s`)\n\n' % root) w('> 生成方式:`sql/tools/extract_table_inventory.py`,扫全仓 mapper XML 的\n') w('> `FROM/JOIN/INTO/UPDATE` 与实体的 `@Table`/`@TableName`。\n') w('> 不含:只由 DDL 变更日志新增、代码从未引用的表。\n\n') w('## 1. 总量与模块归属\n\n') w('共 **%d** 张表:单模块归属 %d 张,被多个模块触碰 %d 张。\n\n' % (len(tables), sum(len(v) for v in owned.values()), len(cross))) w('| 模块 | 表数 | 说明 |\n|---|---:|---|\n') notes = { 'zteits-park': '停车场、车位、设备、进出场', 'zteits-order': '停车订单、共享订单', 'zteits-pay': '账户、客户、结算', 'zteits-bill': '计费规则、活动优惠、黑白名单、会员卡 —— `t_*` 前缀几乎全是它的', 'zteits-sys': '组织、用户、权限、字典', 'zteits-business': '商户/业务域', 'zteits-charge': '充电桩', } for mod, names in sorted(owned.items(), key=lambda kv: -len(kv[1])): w('| `%s` | %d | %s |\n' % (mod, len(names), notes.get(mod, ''))) w('\n## 2. ⚠️ 跨模块表(合并后必须逐个定归属)\n\n') w('这 %d 张表被**多个模块的 SQL 直接引用**。老系统靠"各模块连自己的库"侥幸隔离,\n' % len(cross)) w('合库后它们变成真正可写,必须指定唯一归属模块,其余模块改走 `parking-module-api`:\n\n') w('| 表 | 引用它的模块 | 建议归属 |\n|---|---|---|\n') for name, mods in cross: # 归属建议:取前缀所属模块;tp_ -> park,td_* -> 看具体业务 if name.startswith('tp_'): suggest = '`zteits-park`' elif name.startswith('td_b_') or name.startswith('td_p_'): suggest = '`zteits-pay`(结算/账户)' elif name.startswith('td_c_'): suggest = '`zteits-pay`(客户)' else: suggest = '待定' w('| `%s` | %s | %s |\n' % (name, '、'.join('`%s`' % m for m in mods), suggest)) w('\n## 3. 前缀 → 归属对照\n\n') w('合并六库时**表名不改**,靠前缀区分业务域。前缀与模块**不是**一一对应,此表是证据:\n\n') w('| 前缀 | 表数 | 主要归属模块 |\n|---|---:|---|\n') for pfx, names in sorted(prefix.items(), key=lambda kv: -len(kv[1])): mods = defaultdict(int) for n in names: for m in tables[n]['modules']: mods[m] += 1 top = sorted(mods.items(), key=lambda kv: -kv[1]) w('| `%s_` | %d | %s |\n' % ( pfx, len(names), '、'.join('`%s`(%d)' % (m, c) for m, c in top[:3]))) w('\n## 4. 疑似测试/遗留表(迁移时确认后丢弃)\n\n') # ⚠️ 必须按整词匹配:用 `'temp' in name` 会把 `charger_assess_template`、 # `sms_template_config` 这类**正经的模板表**也当成临时表误报。 suspect_rx = re.compile(r'(^|_)(temp|test|nbtemp|tmp)($|_)') suspects = [n for n in sorted(tables) if suspect_rx.search(n)] for n in suspects: w('- `%s` —— %s\n' % (n, '、'.join('`%s`' % m for m in sorted(tables[n]['modules'])))) w('\n## 5. ⚠️ 分表族(运行期拼后缀,静态数不出真实表名)\n\n') if not dynamic: w('无。\n') else: w('下列 SQL 的表名后缀是**调用方在运行时拼出来的**,所以:\n\n') w('- 真实物理表**比本清单的表名多得多**(每月/每年一张),`SHOW TABLES` 会看到一批\n') w(' "代码里搜不到"的表——它们正是这里拼出来的\n') w('- 建表/清理脚本在仓库外(或定时任务里),合库时要连着这些**分表族**一起搬\n') w('- 迁移改造时**不能**把 `${...}` 写死成某个月,要保留动态拼名逻辑\n\n') w('| 表名前缀 | 变量 | 后缀格式 | 涉及模块 |\n|---|---|---|---|\n') # 后缀格式均已回查调用方核实,不是猜的 hints = { 'tableName': '`yyyyMM`(`SimpleDateFormat("yyyyMM")`)', 'monthStr': '`yyyyMM`(`DateUtil.format(queryMonth,"yyyyMM")`)', 'tableStr': '`yyyyMM`(`DateUtil.format(endTime,"yyyyMM")`)', 'recordDateStr': '`yyyyMM`(`DateUtil.date().toString("yyyyMM")`)', 'year': '`yyyy`(`DateUtil.year(startDate)`)', } for (prefix, var), mods in sorted(dynamic.items()): w('| `%s` | `%s` | %s | %s |\n' % (prefix, var, hints.get(var, '需查调用方'), '、'.join('`%s`' % m for m in sorted(mods)))) w('\n> ⚠️ 顺带查出的**存量缺陷**:`ParkingLotUseStatisticDaoImpl:169` 的\n') w('> `recordStr` 用的是 `DateUtil.date()`(**当前时间**)而不是入参 `endDate` 所在月份。\n') w('> 也就是说它查"任意历史区间的路侧 20 分钟数据"时,实际只查**本月**那张分表 ——\n') w('> 跨月/历史区间查询会静默返回空或错数据。迁移时**不要原样搬**,要么修,\n') w('> 要么明确记录为已知缺陷(修它属于业务逻辑变更,需单独确认)。\n') w('\n## 6. 本抽取**不能**回答的问题(需只读连生产库)\n\n') w('| 问题 | 为什么代码答不了 |\n|---|---|\n') w('| 六库各有哪些表(含代码从未引用的) | 代码只暴露"用到过的表" |\n') w('| 重名表的结构是否一致 | 需要 `SHOW CREATE TABLE` 逐个比对 |\n') w('| 每张表的字符集是否 `utf8mb4` | 非 utf8mb4 的表合并后中文/emoji 会截断 |\n') w('| `dev_park` / `dev_charge` 与正式库的差异 | 需要两个库各导一份结构 |\n') w('| 各表的实际行数(决定迁移窗口) | 需要 `information_schema.TABLES` |\n') w('\n> ⚠️ 老库是**生产库**。上表一律走**只读账号**导出,禁止任何写操作。\n') if __name__ == '__main__': sys.exit(main())