world_sync/scripts/m11b2b_column_diff.py
2026-09-20 20:50:51 +08:00

279 lines
11 KiB
Python
Raw Permalink 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 -*-
"""M11b-2b 双向列集 diffworld_sync 消费方代码引用的列 vs pbl_runtime_ext 权威列集。
PM 裁决 B 的执行要求:「引用后跑双向列集 diff 留证world_sync 消费方代码若依赖旧结构,
适配到权威结构)」。
方向定义:
方向 1代码 -> 权威SQL 字面量中出现的列名必须全部存在于权威列集(缺则 FAIL
方向 2权威 -> 代码):权威列集中未被任何 SQL 引用的列,打印为 informational
(由自增/默认值/写入方填充,不判失败)。
M11b-2b-B1 修正(标识符误判):旧版把「文档字符串里叙述的旧列名/类名/函数名」以及
DB-API 属性、索引名等 Python 侧标识符当成 SQL 列名,导致方向 1 误报 FAIL
AuthorityResolutionError、ConcurrentStateConflict、write_event_with_state、
_assert_contract、execute、format、json、paramstyle、pyformat、qmark、rowcount、
expected_version、uk_es、event_id/updated_by_event 的文档叙述等)。修正为四层过滤:
L1 语句级:字面量必须是真 SQL 语句(以 SQL 动词开头或含 INSERT INTO ... / SELECT ... FROM 签名),
且排除 ast 识别出的 docstring文档叙述不是代码引用
L2 黑名单KNOWN_NON_COLUMN_IDENTIFIERS类名/函数名/DB-API 属性/驱动 paramstyle/索引名/内置模块名);
L3 停用词STOPWORDSSQL 语法词、函数词、驱动方法名);
L4 形态规则:首字母大写=类名、下划线前缀=私有成员、uk_/ix_/idx_/uq_ 前缀=索引名。
用法python3 modules/world_sync/scripts/m11b2b_column_diff.py
"""
import ast
import io
import json
import os
import re
import sys
import tokenize
CONSUMERS = [
"modules/world_sync/world_sync/pbl_runtime_sql.py",
"modules/world_sync/world_sync/pbl_runtime_tx.py",
"modules/world_sync/world_sync/pbl_runtime_schema.py",
]
AUTHORITY = {
"pbl_runtime_event": "modules/pbl_runtime_ext/models/pbl_runtime_event.json",
"pbl_entity_state": "modules/pbl_runtime_ext/models/pbl_entity_state.json",
}
SQL_HINT = re.compile(r"\b(select|insert\s+into|update|delete\s+from|on\s+duplicate)\b", re.IGNORECASE)
# L1a真 SQL 语句的形态签名
SQL_STATEMENT_START = re.compile(
r"^\s*(select|insert\s+into|update|delete\s+from|with|for\s+update|lock\s+in\s+share|"
r"begin|commit|rollback|set\s+transaction|create\s+table|alter\s+table)\b",
re.IGNORECASE)
SQL_STATEMENT_SIGNATURE = re.compile(
r"(\binsert\s+into\s+[\w`.]+\s*\()|(\bselect\b[\s\S]+\bfrom\b)|(\bupdate\s+[\w`.]+\s+set\b)|"
r"(\bdelete\s+from\b)",
re.IGNORECASE)
# SQL 语法词/函数词/驱动方法名,避免误判为列名
STOPWORDS = {
"select", "from", "where", "insert", "into", "values", "update", "set", "delete",
"and", "or", "not", "null", "is", "in", "on", "as", "by", "order", "group", "limit",
"join", "left", "right", "inner", "outer", "distinct", "count", "max", "min", "sum",
"now", "current_timestamp", "true", "false", "begin", "commit", "rollback", "with",
"returning", "if", "exists", "duplicate", "key", "auto_increment", "using", "lock",
"share", "for", "transaction", "level", "read", "committed",
# 驱动/内置模块与游标方法名f-string 残留或文档叙述会被抓成标识符)
"json", "format", "execute", "executemany", "cursor", "connection", "fetchall",
"fetchone", "description", "interval", "utc_timestamp", "date_add", "rowcount",
}
# L2已确认的非列标识符Python 类名 / 函数名 / DB-API 属性 / 索引名 / 内置模块名)。
KNOWN_NON_COLUMN_IDENTIFIERS = {
# 类名 / 异常名
"AuthorityResolutionError", "ConcurrentStateConflict", "ConcurrentConflictError",
"WorldSyncError", "RuntimeContractError",
# 函数 / 方法名
"write_event_with_state", "_assert_contract", "execute", "executemany", "format",
"new_event_id", "new_event_uid", "get_module_dbname", "load_world_sync",
"columns_for", "event_columns", "state_columns",
# 内置模块 / 对象名
"json", "os", "sys", "re", "time", "datetime", "ast", "io",
# DB-API 2.0 属性与参数风格
"paramstyle", "pyformat", "qformat", "qmark", "numeric", "named", "string_format",
"expected_version", "rowcount", "lastrowid",
# 索引名(前缀形态之外的显式登记)
"uk_es", "uk_pre", "ix_es", "ix_tx_group", "ix_created_at",
}
# L4形态规则
INDEX_NAME_PREFIX = re.compile(r"^(uk_|ix_|idx_|uq_)")
TABLE_WORDS = {"pbl_runtime_event", "pbl_entity_state"}
def find_root():
here = os.path.dirname(os.path.abspath(__file__))
cur = here
for _ in range(10):
if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir))
def authority_columns(root, table):
with open(os.path.join(root, AUTHORITY[table]), "r", encoding="utf-8") as fh:
data = json.load(fh)
return [f.get("name") for f in (data.get("fields") or []) if isinstance(f, dict) and f.get("name")]
def docstrings_of(path):
"""L1b收集模块/类/函数的 docstring 文本,文档叙述不参与列集比对。"""
try:
with open(path, "r", encoding="utf-8") as fh:
tree = ast.parse(fh.read(), filename=path)
except Exception: # noqa: BLE001
return set()
out = set()
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
doc = ast.get_docstring(node, clean=False)
if doc:
out.add(doc.strip())
return out
def looks_like_sql_statement(val):
"""L1a必须以 SQL 动词开头,或具备完整语句签名(排除散文式叙述)。"""
if not SQL_HINT.search(val):
return False
if SQL_STATEMENT_START.match(val):
return True
return bool(SQL_STATEMENT_SIGNATURE.search(val))
def sql_literals(path):
"""用 tokenize 取出文件中的字符串字面量,仅保留真 SQL 语句(排除 docstring"""
with open(path, "rb") as fh:
raw = fh.read()
docs = docstrings_of(path)
out = []
try:
for tok in tokenize.tokenize(io.BytesIO(raw).readline):
if tok.type != tokenize.STRING:
continue
try:
val = ast_literal_eval(tok.string)
except Exception: # noqa: BLE001
continue
if not isinstance(val, str):
continue
if val.strip() in docs: # L1b 文档叙述
continue
if not looks_like_sql_statement(val): # L1a 非语句形态
continue
out.append(val)
except (tokenize.TokenError, IndentationError, SyntaxError):
pass
return out
def ast_literal_eval(src):
"""轻量解析字符串字面量(含 f-string 前缀),失败返回 None。"""
s = src
m = re.match(r"^([a-zA-Z]*)", s)
prefix = m.group(1).lower() if m else ""
body = s[len(prefix):]
if "f" in prefix: # f-string把 {expr} 替换为占位,仅取字面部分
body = re.sub(r"\{[^{}]*\}", " ", body)
if "b" in prefix:
body = body.encode("latin-1", "ignore").decode("unicode_escape")
try:
import ast as _ast
return _ast.literal_eval(body) if body[:1] in "\"'" else body
except Exception: # noqa: BLE001
return body
try:
import ast as _ast
return _ast.literal_eval(body)
except Exception: # noqa: BLE001
return None
def is_non_column_identifier(tok):
"""L2/L3/L4 标识符过滤:黑名单 -> 停用词 -> 形态规则。"""
if not tok:
return True
if tok in KNOWN_NON_COLUMN_IDENTIFIERS:
return True
if tok.lower() in STOPWORDS:
return True
if tok[0].isupper(): # 类名/异常名(列名规范为小写下划线)
return True
if tok.startswith("_"): # 私有成员
return True
if INDEX_NAME_PREFIX.match(tok.lower()): # 索引名
return True
return False
def columns_from_sql(sql):
cols = set()
cols.update(re.findall(r"`([a-zA-Z_][a-zA-Z_0-9]*)`", sql))
for grp in re.findall(r"INSERT\s+INTO\s+[\w`.]+\s*\(([^)]*)\)", sql, re.IGNORECASE):
for tok in grp.split(","):
tok = tok.strip().strip("`")
if re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", tok or ""):
cols.add(tok)
for grp in re.findall(r"\bSET\s+(.+?)(?:\bWHERE\b|$)", sql, re.IGNORECASE | re.DOTALL):
cols.update(re.findall(r"([a-zA-Z_][a-zA-Z_0-9]*)\s*=", grp))
for grp in re.findall(r"\bWHERE\s+(.+?)$", sql, re.IGNORECASE | re.DOTALL):
for tok in re.findall(r"([a-zA-Z_][a-zA-Z_0-9]*)\s*(?:=|<|>|!|in\b|is\b)", grp, re.IGNORECASE):
cols.add(tok)
for grp in re.findall(r"\b(?:ORDER\s+BY|GROUP\s+BY)\s+([\w`,\s.]+)", sql, re.IGNORECASE):
for tok in grp.split(","):
tok = tok.strip().strip("`").split()[-1] if tok.strip() else ""
if re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", tok or ""):
cols.add(tok)
for tok in re.findall(r"\b(?:pbl_runtime_event|pbl_entity_state)\.([a-zA-Z_][a-zA-Z_0-9]*)", sql):
cols.add(tok)
return {c for c in cols if c not in TABLE_WORDS and not is_non_column_identifier(c)}
def main():
root = find_root()
print("workspace root: %s" % root)
sql_total = []
ident = set()
scanned = []
for rel in CONSUMERS:
path = os.path.join(root, rel)
if not os.path.isfile(path):
print(" SKIP (absent): %s" % rel)
continue
scanned.append(rel)
lits = sql_literals(path)
sql_total.extend(lits)
for s in lits:
ident |= columns_from_sql(s)
print("consumer files scanned: %d -> %s" % (len(scanned), scanned))
print("SQL statement literals kept: %d, distinct column identifiers: %d" % (len(sql_total), len(ident)))
print("identifier filter layers: L1 statement-gate+docstring-exclusion, "
"L2 known_non_column=%d, L3 stopwords=%d, L4 shape_rules=3"
% (len(KNOWN_NON_COLUMN_IDENTIFIERS), len(STOPWORDS)))
auth_cols = {}
for table in AUTHORITY:
auth_cols[table] = authority_columns(root, table)
print("\nauthority columns [%s] (%d cols): %s" % (table, len(auth_cols[table]), auth_cols[table]))
print("\n--- 方向 1: 代码 SQL 引用列 ⊆ 权威列集(必须全部成立)---")
bad = []
for name in sorted(ident):
hit = [t for t in auth_cols if name in auth_cols[t]]
print(" %-22s %s %s" % (name, "OK " if hit else "MISS", ("->" + ",".join(hit)) if hit else "(不在任何权威列集)"))
if not hit:
bad.append(name)
print("\n--- 方向 2: 权威列集中未被 SQL 显式引用者informational---")
for table in AUTHORITY:
unused = [c for c in auth_cols[table] if c not in ident]
print(" [%s] unreferenced=%d %s" % (table, len(unused), unused))
print("\ncode columns checked: %d, missing from authority: %d" % (len(ident), len(bad)))
if bad:
print("MISSING: %s" % bad)
print("RESULT: FAIL")
return 1
print("RESULT: PASS")
return 0
if __name__ == "__main__":
sys.exit(main())