extract_table_inventory.py 12.8 KB
#!/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 '<mapper' not in text:
                    continue
                # 先去注释,避免注释里的 SQL 片段污染
                text = re.sub(r'<!--.*?-->', ' ', text, flags=re.DOTALL)
                for m in TABLE_REF.finditer(text):
                    # ⚠️ `<update id="updateXxx">` / `<insert id=...>` 这类**标签属性**也会命中
                    #    (标签名恰好是 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())