# -*- coding: utf-8 -*- """M1b 表定义规范化工具:把 models/m1b/*.json 归一到 database-table-definition-spec 四段式。 背景(QC 退回意见 #8):M1b 首版 models/m1b/*.json 用了「summary 为长字符串 + fields 用 varchar/bigint/longtext 具体类型 + codes 为 dict」的自创格式,既不符合 database-table-definition-spec(四段式必须都是**数组**、type 必须是**抽象类型**), 也让 tools/m1b_selfcheck.py 的 B2/B3/B4/B5 检查直接抛 AttributeError。 本脚本做三件事,全部以 sql/m1b_ddl.sql 为唯一权威来源(保证 models↔DDL 一一对应): 1. 解析 DDL → {table: {cols:[...], indexes:{name:(cols,unique)}, defaults:{col:default}}} 2. 把 DDL 的具体类型映射为规范抽象类型(varchar→str / bigint→long / longtext→text ...) 3. 生成规范四段式: - summary : [{"name","title","primary":["id"],"catelog","comment"}] - fields : [{"name","title","type","length","dec","nullable","default","comment"}] - indexes : [{"name","idxtype":"unique|index","idxfields":[...]}] (PRIMARY 归 summary.primary) - codes : [{"field","table":"appcodes_kv","valuefield":"k","textfield":"v", "cond":"parentid='<字典组>'"}] (cond 必须 parentid=) 旧文件里的中文 label / comment / 字典组名会被完整继承(不丢设计信息), 字典组名从旧 fields[].comment 的「字典 pbl_xxx」中提取。 用法: python3 tools/m1b_normalize_models.py # 就地重写 models/m1b/*.json python3 tools/m1b_normalize_models.py --check # 只校验不改写,违规 rc=1 """ import json import os import re import sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DDL = os.path.join(REPO, "sql", "m1b_ddl.sql") MDIR = os.path.join(REPO, "pbl_blueprint", "models", "m1b") # 表中文名(summary[0].title)与分类(catelog) TABLE_META = { "pbl_blueprint_template": ("PBL蓝图模板", "entity"), "pbl_blueprint_ref": ("PBL蓝图外部关联", "relation"), "pbl_subobject_ext": ("PBL子对象扩展属性", "entity"), "pbl_ext_field_def": ("PBL扩展字段定义", "dimession"), } # DDL 具体类型 → 规范抽象类型(database-table-definition-spec 支持表) TYPE_MAP = { "varchar": "str", "char": "char", "tinytext": "text", "text": "text", "mediumtext": "text", "longtext": "text", "json": "text", "tinyint": "short", "smallint": "short", "mediumint": "int", "int": "int", "integer": "int", "bigint": "long", "float": "float", "double": "double", "decimal": "double", "numeric": "double", "date": "date", "time": "time", "datetime": "datetime", "timestamp": "timestamp", "blob": "bin", "longblob": "bin", "varbinary": "bin", "binary": "bin", } # 需要 length 的抽象类型 NEED_LENGTH = {"str", "char"} # 需要 length + dec 的抽象类型 NEED_DEC = {"float", "double", "ddouble", "decimal"} def read_ddl(): with open(DDL, "r", encoding="utf-8") as f: return f.read() def parse_ddl(body): """解析 DDL → {table: {"cols":[(name,sqltype,len,dec,notnull,default)], "indexes":{name:(cols,unique)}, "pk":[cols]}}""" out = {} pat = re.compile( r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?(\w+)`?\s*\((.*?)\n\)\s*ENGINE[^;]*;", re.I | re.S) for m in pat.finditer(body): tname, inner = m.group(1), m.group(2) cols, idx, pk = [], {}, [] for raw in inner.splitlines(): s = raw.strip().rstrip(",").strip() if not s or s.startswith("--"): continue up = s.upper() if up.startswith("PRIMARY KEY"): pm = re.search(r"\(([^)]*)\)", s) if pm: pk = [c.strip().strip("`") for c in pm.group(1).split(",")] continue if up.startswith("UNIQUE KEY") or up.startswith("KEY ") \ or up.startswith("INDEX ") or up.startswith("UNIQUE INDEX"): nm = re.match( r"(?:UNIQUE\s+)?(?:KEY|INDEX)\s+`?(\w+)`?\s*\(([^)]*)\)", s, re.I) if nm: cs = [c.strip().strip("`") for c in nm.group(2).split(",")] idx[nm.group(1)] = (cs, up.startswith("UNIQUE")) continue if up.startswith("CONSTRAINT") or up.startswith("FOREIGN KEY"): continue cm = re.match(r"`(\w+)`\s+([A-Za-z]+)\s*(\(([^)]*)\))?(.*)$", s) if not cm: continue name, sqltype, _paren, args, tail = cm.groups() sqltype = sqltype.lower() ln, dec = None, None if args: parts = [x.strip() for x in args.split(",")] try: ln = int(parts[0]) except ValueError: ln = None if len(parts) > 1: try: dec = int(parts[1]) except ValueError: dec = None notnull = "NOT NULL" in tail.upper() dm = re.search(r"DEFAULT\s+('(?:[^']*)'|NULL|[\w.+-]+)", tail, re.I) default = None if dm: dv = dm.group(1) if dv.upper() == "NULL": default = None elif dv.startswith("'") and dv.endswith("'"): default = dv[1:-1] else: try: default = int(dv) except ValueError: default = dv cols.append((name, sqltype, ln, dec, notnull, default)) out[tname] = {"cols": cols, "indexes": idx, "pk": pk or ["id"]} return out def abstract_type(sqltype, ln, dec): """DDL 具体类型 → (抽象类型, length, dec);未知类型抛错,禁止静默降级。""" at = TYPE_MAP.get(sqltype) if at is None: raise ValueError("未登记的 DDL 类型:%s(请在 TYPE_MAP 中补映射)" % sqltype) length = None d = None if at in NEED_LENGTH: if not ln or ln <= 0: raise ValueError("抽象类型 %s 必须有正整数 length(DDL 类型 %s)" % (at, sqltype)) length = int(ln) if at in NEED_DEC: length = int(ln) if ln else 18 d = int(dec) if dec else 2 return at, length, d def load_legacy(table): """读旧 models 文件,返回 {col: {"title":..,"comment":..,"dict":..}} 供继承。""" fp = os.path.join(MDIR, table + ".json") info = {} if not os.path.isfile(fp): return info try: with open(fp, "r", encoding="utf-8") as f: d = json.load(f) except Exception: return info fields = d.get("fields") if isinstance(fields, dict): # M1a 风格:fields 为 dict items = [(k, v) for k, v in fields.items()] elif isinstance(fields, list): # M1b 首版:fields 为 list items = [(x.get("name"), x) for x in fields if isinstance(x, dict)] else: items = [] for name, fd in items: if not name: continue title = fd.get("label") or fd.get("title") or name comment = fd.get("comment") or fd.get("summary") or "" dm = re.search(r"字典\s+([A-Za-z_][\w]*)", comment) info[name] = {"title": title, "comment": comment, "dict": dm.group(1) if dm else None} return info def build_model(table, spec): legacy = load_legacy(table) title, catelog = TABLE_META.get(table, (table, "entity")) fields = [] for (name, sqltype, ln, dec, notnull, default) in spec["cols"]: at, length, d = abstract_type(sqltype, ln, dec) lg = legacy.get(name, {}) fd = { "name": name, "title": lg.get("title") or name, "type": at, } if length is not None: fd["length"] = length if d is not None: fd["dec"] = d if notnull: fd["nullable"] = "no" if default is not None and default != "": fd["default"] = default elif default == "": fd["default"] = "" if lg.get("comment"): fd["comment"] = lg["comment"] fields.append(fd) indexes = [] for iname in sorted(spec["indexes"]): cols, uniq = spec["indexes"][iname] indexes.append({ "name": iname, "idxtype": "unique" if uniq else "index", "idxfields": list(cols), }) codes = [] seen = set() for fd in fields: grp = legacy.get(fd["name"], {}).get("dict") if not grp or fd["name"] in seen: continue seen.add(fd["name"]) codes.append({ "field": fd["name"], "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='%s'" % grp, }) return { "summary": [{ "name": table, "title": title, "primary": list(spec["pk"]), "catelog": catelog, "comment": "M1b 表;索引一律 tenant_key 打头(tenant_key=COALESCE(tenant_id," "'__PLATFORM__'),由 m1b_db.tenant_key() 统一计算);无 FOREIGN KEY;" "对 world/scene/entity/script 等复用域基表零改动(Q-OPEN-3)。", }], "fields": fields, "indexes": indexes, "codes": codes, } def validate(model, table, spec): """规范符合性自查,返回问题清单(空=通过)。""" bad = [] for seg in ("summary", "fields", "indexes", "codes"): if not isinstance(model.get(seg), list): bad.append("%s: %s 不是数组" % (table, seg)) sm = model.get("summary") or [] if not (sm and isinstance(sm[0], dict)): bad.append("%s: summary[0] 缺失" % table) else: if sm[0].get("name") != table: bad.append("%s: summary[0].name=%r != 表名" % (table, sm[0].get("name"))) if not isinstance(sm[0].get("primary"), list) or not sm[0].get("primary"): bad.append("%s: summary[0].primary 必须是非空数组" % table) cols_ddl = [c[0] for c in spec["cols"]] cols_model = [f.get("name") for f in model.get("fields", [])] if cols_ddl != cols_model: bad.append("%s: 字段顺序/集合与 DDL 不一致 仅DDL=%s 仅models=%s" % (table, sorted(set(cols_ddl) - set(cols_model)), sorted(set(cols_model) - set(cols_ddl)))) for f in model.get("fields", []): t = f.get("type") if t not in TYPE_MAP.values(): bad.append("%s.%s: 非法抽象类型 %r" % (table, f.get("name"), t)) if t in NEED_LENGTH and not (isinstance(f.get("length"), int) and f["length"] > 0): bad.append("%s.%s: %s 缺正整数 length" % (table, f.get("name"), t)) if t in NEED_DEC: if not (isinstance(f.get("length"), int) and f["length"] > 0): bad.append("%s.%s: %s 缺 length" % (table, f.get("name"), t)) if not (isinstance(f.get("dec"), int) and f["dec"] > 0): bad.append("%s.%s: %s 缺 dec" % (table, f.get("name"), t)) if f.get("name") == "id" and not (t == "str" and f.get("length", 0) >= 32 or t == "long"): bad.append("%s.id: 主键类型异常 %r" % (table, t)) idx_model = {i.get("name"): (list(i.get("idxfields") or []), i.get("idxtype")) for i in model.get("indexes", [])} idx_ddl = {k: (list(v[0]), "unique" if v[1] else "index") for k, v in spec["indexes"].items()} if sorted(idx_model) != sorted(idx_ddl): bad.append("%s: 索引名与 DDL 不一致 仅models=%s 仅DDL=%s" % (table, sorted(set(idx_model) - set(idx_ddl)), sorted(set(idx_ddl) - set(idx_model)))) for k in idx_model: if k in idx_ddl and idx_model[k] != idx_ddl[k]: bad.append("%s.%s: 索引定义不一致 models=%s DDL=%s" % (table, k, idx_model[k], idx_ddl[k])) if not isinstance(idx_model[k][0], list) or not idx_model[k][0]: bad.append("%s.%s: idxfields 必须是非空数组" % (table, k)) if idx_model[k][0] and idx_model[k][0][0] != "tenant_key": bad.append("%s.%s: 索引首列不是 tenant_key(%s)" % (table, k, idx_model[k][0][0])) for c in model.get("codes", []): if c.get("table") == "appcodes_kv" and "parentid=" not in (c.get("cond") or ""): bad.append("%s.codes[%s]: appcodes_kv 的 cond 必须用 parentid=" % (table, c.get("field"))) if "." in (c.get("table") or ""): bad.append("%s.codes[%s]: table 禁用 module.table 点号写法" % (table, c.get("field"))) flds = [c.get("field") for c in model.get("codes", [])] if len(flds) != len(set(flds)): bad.append("%s: codes 存在重复 field(会触发 Duplicate column name)" % table) return bad def main(): check_only = "--check" in sys.argv body = read_ddl() ddl = parse_ddl(body) expect = sorted(TABLE_META) if sorted(ddl) != expect: print("FAIL DDL 表集合=%s 期望=%s" % (sorted(ddl), expect)) return 1 if not os.path.isdir(MDIR): os.makedirs(MDIR) all_bad = [] for t in expect: model = build_model(t, ddl[t]) all_bad += validate(model, t, ddl[t]) fp = os.path.join(MDIR, t + ".json") if check_only: old = None if os.path.isfile(fp): try: with open(fp, "r", encoding="utf-8") as f: old = json.load(f) except Exception: old = None if old != model: all_bad.append("%s: 磁盘文件与规范四段式不一致(需重写)" % t) else: with open(fp, "w", encoding="utf-8") as f: json.dump(model, f, ensure_ascii=False, indent=2) f.write("\n") print("WROTE %s fields=%d indexes=%d codes=%d" % (os.path.relpath(fp, REPO), len(model["fields"]), len(model["indexes"]), len(model["codes"]))) if all_bad: print("VALIDATE_FAIL %d 项:" % len(all_bad)) for b in all_bad: print(" -", b) return 1 print("VALIDATE_OK 4 表 models 四段式全部符合 database-table-definition-spec") return 0 if __name__ == "__main__": sys.exit(main())