world_sync/scripts/validate_models_json.py
2026-09-20 18:04:29 +08:00

357 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""validate_models_json —— database-table-definition-spec 机械校验器M11b-2b-A 修复版)。
QC #4/#7/#9/#10 要求:表定义 JSON 必须是 spec 规定的四段式summary/fields/indexes/
codes类型必须是 spec 声明的抽象类型,主键 id 必须是 str/length>=32索引必须用
``idxtype`` + ``idxfields``。本脚本把这些规则变成**可执行的断言**,任何一份 models/*.json
不合规就非零退出(真源不合规同样 FAIL —— QC #9真源不合规则引用方不可能合规
本版本修复 QC 退回项(只改本脚本,不改任何 models/*.json
QC #2 抽象类型误报纯抽象类型名datetime/timestamp/date/time/text/long/…)一律放行,
只有 type 字符串本身含括号或空格(方言写法 varchar(64)/decimal(15,2)/datetime(3)
或本身就是 spec 不存在的非抽象类型名varchar/bigint/json/blob/…)才判违规。
QC #3 CLI 与文档一致argparse 真正提供 --allow-registered-deviation见下方「用法」
QC #4 root_keys_ok由 validate_model 显式计算 (not missing and not extra) 并随结果返回,
main() 直接打印该布尔值,不再用「错误串里是否含『四段式』」猜测。
QC #5 引用式 key/schemaspec 不承认引用式结构,直接判 ERROR 并提示改为内联四段式;
COMPLIANCE_DEVIATIONS 中针对 pbl_runtime_event/pbl_entity_state 的 4 条失效登记项
一并删除(引用式在根键检查处即 FAIL根本到不了 fields 校验,登记形同虚设)。
已登记偏离(唯一允许的偏离,需 --allow-registered-deviation 显式声明才放行):
主键 id 非 str(32) 或 id 带 auto_increment且已登记在 COMPLIANCE_DEVIATIONS 中
(典型场景:分区表物理主键需要 long + auto_increment。未传该开关时这类项仍判 ERROR
并打印「需 --allow-registered-deviation 放行」提示;传入时逐条打印 DEVIATION(registered)
明细,不静默放过。当前登记表为空(引用式失效项已删除),后续若真源确需偏离再登记。
用法:
python validate_models_json.py <models目录1> [<目录2> ...]
python validate_models_json.py --allow-registered-deviation <models目录>
python validate_models_json.py --files a.json b.json <models目录>
python validate_models_json.py --json-report out.json <models目录>
"""
import argparse
import json
import os
import sys
SPEC_ROOT_KEYS = ("summary", "fields", "indexes", "codes")
SUMMARY_KEYS = ("name", "title", "primary", "catelog")
SUMMARY_OPTIONAL = ("comment",)
FIELD_KEYS = ("name", "title", "type", "length", "dec", "nullable", "default")
INDEX_KEYS = ("name", "idxtype", "idxfields")
CODE_KEYS = ("field", "table", "valuefield", "textfield", "cond")
ABSTRACT_TYPES = {
"str": ("length",), "char": ("length",),
"short": (), "int": (), "long": (),
"float": ("length", "dec"), "double": ("length", "dec"),
"ddouble": ("length", "dec"), "decimal": ("length", "dec"),
"date": (), "time": (), "datetime": (), "timestamp": (),
"text": (), "bin": (),
}
# QC #2spec 明确不存在的非抽象类型名type 本身就是这些词即违规)。
# 注意:这里**不得**出现 datetime/timestamp/date/time/text/long 等 spec 合法抽象类型,
# 也不得出现带括号的写法带括号的情况由「type 含括号/空格 = 方言写法」分支统一拦截)。
NATIVE_TYPE_WORDS = {"varchar", "nvarchar", "bigint", "tinyint", "mediumint",
"smallint", "integer", "int2", "int4", "int8",
"json", "jsonb", "blob", "clob", "bytea", "varbinary",
"numeric", "boolean", "bool", "enum", "set", "unsigned",
"serial", "bigserial", "uuid", "money"}
NULLABLE_VALUES = ("yes", "no")
# QC #5引用式写法key=schema + schema 段)不被 spec 承认 —— 直接 FAIL请改为内联四段式。
# 因此这里不再为引用式文件登记任何偏离项(到不了 fields 校验,登记永不命中)。
#
# 已登记的偏离:(表名, 列名, 偏离类型) -> 说明 —— 只有登记过的才允许放行,且必须打印
COMPLIANCE_DEVIATIONS = {}
DEVIATION_HINT = "需 --allow-registered-deviation 放行"
def _is_reference_style(data):
"""识别引用式结构:{"key": "schema", "schema": {...}} 之类spec 不承认)。"""
if not isinstance(data, dict):
return False
if str(data.get("key", "")).lower() == "schema" and "schema" in data:
return True
# 变体:只有 schema 段而无四段式,同样属于引用式(把权威定义指向别处)
if "schema" in data and not any(k in data for k in SPEC_ROOT_KEYS):
return True
return False
def check_type_value(type_value):
"""返回该 type 写法的问题列表spec 只允许抽象类型)。
判定顺序QC #2
1) 非字符串/空串 → 违规;
2) 含括号或空格varchar(64)/decimal(15,2)/datetime(3)/timestamp with time zone
→ 方言具体类型,违规;
3) 纯抽象类型名且在 ABSTRACT_TYPES 内datetime/timestamp/text/long/date/time…
→ **一律放行**,不再二次怀疑为原生类型;
4) 名字命中 NATIVE_TYPE_WORDSvarchar/bigint/json/blob/…)→ 违规;
5) 其余不在抽象类型表内的名字 → 违规。
"""
problems = []
if not isinstance(type_value, str) or not type_value.strip():
problems.append("type 必须是非空字符串")
return problems
low = type_value.strip().lower()
if "(" in low or ")" in low or " " in low:
problems.append("type=%r 是数据库方言具体类型spec 禁止,应使用抽象类型 + length/dec" % type_value)
return problems
if low in ABSTRACT_TYPES:
return problems # 合法抽象类型,直接放行
if low in NATIVE_TYPE_WORDS:
problems.append("type=%r 是数据库原生类型spec 无此抽象类型,应改用 long/text/int/str 等)" % type_value)
return problems
problems.append("type=%r 不在 spec 抽象类型表内" % type_value)
return problems
def validate_model(path, strict_id=True):
"""校验一份表定义 JSON。
返回 4-tuple: (errors, deviations, table_name, root_keys_ok)
root_keys_ok = (not missing and not extra)在函数内显式计算QC #4
"""
errors = []
deviations = []
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except ValueError as exc:
return (["JSON 不可解析: %s" % exc], [], None, False)
except OSError as exc:
return (["文件不可读: %s" % exc], [], None, False)
if not isinstance(data, dict):
return (["根对象必须是 JSON object"], [], None, False)
# QC #5引用式 key/schema 写法 —— spec 不承认,直接 FAIL不做跨模块解引用
if _is_reference_style(data):
errors.append("引用式写法key=schema / schema 指向他处权威定义)不被 database-table-definition-spec "
"承认,须在本文件内联四段式 summary/fields/indexes/codes")
return (errors, deviations, None, False)
extra = [k for k in data if k not in SPEC_ROOT_KEYS]
missing = [k for k in SPEC_ROOT_KEYS if k not in data]
root_keys_ok = (not missing and not extra) # QC #4显式计算不靠字符串猜测
if extra:
errors.append("根键多出 spec 未定义的段: %s(四段式只允许 summary/fields/indexes/codes"
% ", ".join(sorted(extra)))
if missing:
errors.append("根键缺少 spec 必需段: %s" % ", ".join(missing))
if errors:
return (errors, deviations, None, root_keys_ok)
summary = data["summary"]
if not isinstance(summary, list) or len(summary) != 1 or not isinstance(summary[0], dict):
errors.append("summary 必须是恰好 1 条记录的数组")
return (errors, deviations, None, root_keys_ok)
s = summary[0]
table = s.get("name")
for k in ("name", "title"):
if not s.get(k):
errors.append("summary[0].%s 必填" % k)
unknown = [k for k in s if k not in SUMMARY_KEYS + SUMMARY_OPTIONAL]
if unknown:
errors.append("summary[0] 含 spec 未定义键: %s(应并入 summary[0].comment 文本或删除)"
% ", ".join(sorted(unknown)))
primary = s.get("primary")
if not isinstance(primary, list) or not primary:
errors.append("summary[0].primary 必须是非空数组(字符串会渲染成 'i,d'")
primary = []
fields = data["fields"]
if not isinstance(fields, list) or not fields:
errors.append("fields 必须是非空数组")
return (errors, deviations, table, root_keys_ok)
names = []
for idx, f in enumerate(fields):
where = "fields[%d]" % idx
if not isinstance(f, dict):
errors.append("%s 必须是 object" % where)
continue
name = f.get("name")
where = "fields[%d](%s)" % (idx, name or "?")
if not name or not isinstance(name, str):
errors.append("%s.name 必填" % where)
continue
names.append(name)
if not f.get("title"):
errors.append("%s.title 必填DDL COMMENT 用)" % where)
unknown = [k for k in f if k not in FIELD_KEYS]
if unknown:
errors.append("%s 含 spec 未定义键: %s" % (where, ", ".join(sorted(unknown))))
t = f.get("type")
errors.extend("%s: %s" % (where, m) for m in check_type_value(t))
req = ABSTRACT_TYPES.get(str(t).strip().lower(), ())
for need in req:
v = f.get(need)
if not isinstance(v, int) or isinstance(v, bool) or v <= 0:
errors.append("%s: type=%s 必须带正整数 %s(实测 %r" % (where, t, need, v))
nullable = f.get("nullable")
if nullable is not None and nullable not in NULLABLE_VALUES:
errors.append('%s.nullable 只能是 "yes"|"no"(实测 %r' % (where, nullable))
if name == "id":
if str(t).strip().lower() != "str" or not (isinstance(f.get("length"), int) and f["length"] >= 32):
key = (table, "id", "id-not-str32")
if key in COMPLIANCE_DEVIATIONS:
if strict_id:
errors.append("%s: 主键 id 必须 str 且 length>=32spec 规则),实测 type=%r length=%r"
" —— 该项已登记偏离,%s"
% (where, t, f.get("length"), DEVIATION_HINT))
else:
deviations.append("%s: id 非 str(32)type=%r length=%r)—— 已登记偏离:%s"
% (where, t, f.get("length"), COMPLIANCE_DEVIATIONS[key]))
else:
errors.append("%s: 主键 id 必须 str 且 length>=32spec 规则),实测 type=%r length=%r"
% (where, t, f.get("length")))
if f.get("auto_increment"):
key = (table, "id", "auto-increment")
if key in COMPLIANCE_DEVIATIONS:
if strict_id:
errors.append("%s: id auto_increment —— 该项已登记偏离,%s"
% (where, DEVIATION_HINT))
else:
deviations.append("%s: id auto_increment —— 已登记偏离:%s"
% (where, COMPLIANCE_DEVIATIONS[key]))
else:
errors.append("%s: auto_increment 未在偏离登记表中spec 无此键)" % where)
dup = [n for n in set(names) if names.count(n) > 1]
if dup:
errors.append("字段名重复: %s" % ", ".join(sorted(dup)))
for p in primary:
if p not in names:
errors.append("primary 引用了不存在的列: %s" % p)
for idx, i in enumerate(data["indexes"] or []):
where = "indexes[%d](%s)" % (idx, i.get("name") if isinstance(i, dict) else "?")
if not isinstance(i, dict):
errors.append("%s 必须是 object" % where)
continue
unknown = [k for k in i if k not in INDEX_KEYS]
if unknown:
errors.append("%s 含 spec 未定义键: %sunique/fields 写法不被 DDL 模板识别)"
% (where, ", ".join(sorted(unknown))))
if not i.get("name"):
errors.append("%s.name 必填" % where)
if i.get("idxtype") not in ("unique", "index"):
errors.append('%s.idxtype 必须是 "unique"|"index"(实测 %r' % (where, i.get("idxtype")))
idxf = i.get("idxfields")
if not isinstance(idxf, list) or not idxf:
errors.append("%s.idxfields 必须是非空数组" % where)
continue
for c in idxf:
if c not in names:
errors.append("%s.idxfields 引用不存在的列: %s" % (where, c))
if str(i.get("name")) == "PRIMARY":
errors.append("%s: 主键不写进 indexesspec 用 summary[0].primary 声明)" % where)
idx_names = [i.get("name") for i in data["indexes"] or [] if isinstance(i, dict)]
dup = [n for n in set(idx_names) if idx_names.count(n) > 1]
if dup:
errors.append("索引名重复: %s" % ", ".join(sorted(dup)))
for idx, c in enumerate(data["codes"] or []):
where = "codes[%d]" % idx
if not isinstance(c, dict):
errors.append("%s 必须是 object" % where)
continue
unknown = [k for k in c if k not in CODE_KEYS]
if unknown:
errors.append("%s 含 spec 未定义键: %s" % (where, ", ".join(sorted(unknown))))
for need in ("field", "table", "valuefield", "textfield"):
if not c.get(need):
errors.append("%s.%s 必填" % (where, need))
if c.get("field") and c["field"] not in names:
errors.append("%s.field 引用不存在的列: %s" % (where, c["field"]))
if c.get("table") == "appcodes_kv" and "." in str(c.get("cond", "")):
errors.append("%s: appcodes_kv 的 cond 必须用 parentid= 分组键" % where)
if "." in str(c.get("table", "")):
errors.append("%s: codes.table 禁止 module.table 点号写法" % where)
return (errors, deviations, table, root_keys_ok)
def collect_targets(dirs, only_files=None):
"""展开目录列表为待校验文件路径(目录不存在则 SKIP 提示)。"""
targets = []
for d in dirs:
if not os.path.isdir(d):
print("SKIP(not a dir): %s" % d)
continue
for name in sorted(os.listdir(d)):
if not name.endswith(".json"):
continue
if only_files and name not in only_files:
continue
targets.append(os.path.join(d, name))
return targets
def main(argv=None):
ap = argparse.ArgumentParser(description="database-table-definition-spec 机械校验")
# nargs="*"`--files a.json /tmp` 这种写法下 argparse 会把目录吞进 --files
# 位置参数可能为空,故这里不强制 nargs="+",改由下方规范化后统一校验非空。
ap.add_argument("dirs", nargs="*", help="models 目录(校验其下所有 *.json")
ap.add_argument("--files", nargs="*", default=None,
help="只校验指定文件名(如 --files pbl_runtime_event.json models"
"非 .json 结尾的参数会被当作额外的 models 目录")
ap.add_argument("--json-report", default=None)
# QC #3docstring 声明的开关必须真实存在于 argparse否则按文档用法执行会 unrecognized arguments
ap.add_argument("--allow-registered-deviation", dest="allow_registered_deviation",
action="store_true", default=False,
help="放行 COMPLIANCE_DEVIATIONS 中已登记的偏离项(未传时这类项仍判 ERROR 并提示需本开关)")
args = ap.parse_args(argv)
# --files 用 nargs="*"argparse 会把后面的目录一并吞进来(如 `--files a.json /tmp`)。
# 规范化:以 .json 结尾的作为文件名过滤,其余作为额外目录,保证文档用法可直接执行。
extra_dirs = []
name_filters = []
for tok in (args.files or []):
if str(tok).endswith(".json"):
name_filters.append(str(tok))
else:
extra_dirs.append(str(tok))
dirs = list(args.dirs or []) + extra_dirs
if not dirs:
ap.error("至少提供一个 models 目录(或 --files <name.json> <目录>")
strict_id = not args.allow_registered_deviation
if args.allow_registered_deviation:
print("MODE: --allow-registered-deviation已登记偏离项放行登记项 %d 条)"
% len(COMPLIANCE_DEVIATIONS))
total_files = 0
total_errors = 0
total_deviations = 0
report = []
for path in collect_targets(dirs, name_filters or None):
errors, deviations, table, root_keys_ok = validate_model(path, strict_id=strict_id)
total_files += 1
total_errors += len(errors)
total_deviations += len(deviations)
report.append({"path": path, "table": table, "root_keys_ok": root_keys_ok,
"errors": errors, "deviations": deviations})
print("---- %s (table=%s)" % (path, table))
print(" root_keys_ok=%s" % root_keys_ok) # QC #4打印显式计算的布尔值
for e in errors:
print(" ERROR: %s" % e)
for v in deviations:
print(" DEVIATION(registered): %s" % v)
if not errors:
print(" OK: 符合 database-table-definition-spec偏离 %d 条已登记)" % len(deviations))
print("=" * 60)
print("files=%d errors=%d registered_deviations=%d" % (total_files, total_errors, total_deviations))
print("RESULT: %s" % ("PASS" if total_errors == 0 else "FAIL"))
if args.json_report:
with open(args.json_report, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=1)
return 0 if total_errors == 0 else 1
if __name__ == "__main__":
sys.exit(main())