pbl_evidence/scripts/gen_ddl_from_models.py
2026-09-23 00:03:13 +08:00

206 lines
8.8 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 -*-
"""M5a-DISC.1:以 ``models/*.json`` 为**单一真源**再生成 MariaDB DDL。
背景(QC 退回 #4):仓库内 ``sql/pbl_evidence.sql`` 是旧模型残留——把 ``id`` 渲染成
``BIGINT UNSIGNED NOT NULL AUTO_INCREMENT``(与 models 的 ``str(32)`` 主键冲突),
引用字段 ``artifact_id/session_id/blueprint_id`` 渲染成 ``BIGINT UNSIGNED``(与 models
的 ``str(32)`` 冲突),并且存在 ``NOT NULL NOT NULL`` / ``NULL NULL`` 重复渲染、
``COMMENT`` 无引号等生成缺陷。宿主 ``xls2ddl/json2ddl`` 在本开发机未安装
(``import xls2ddl`` ModuleNotFoundError),故本脚本作为**仓库内可复现**的等价生成器:
纯标准库、无第三方依赖,读 models 四段式 JSON → 输出 MariaDB 方言 DDL。
生成规则(与 database-table-definition-spec 对齐):
* 抽象类型 → MariaDB 原生类型映射表见 ``TYPE_MAP``;``text`` → ``LONGTEXT``。
* ``nullable == "no"`` → ``NOT NULL``(**只渲染一次**,其他写法一律按可空,不重复输出)。
* ``default`` 存在 → ``DEFAULT`` 字面量(字符串加单引号并转义,数字/None 原样)。
* 主键取 ``summary[0].primary``(数组),**绝不输出 AUTO_INCREMENT**——主键由应用层
``pbl_evidence/pk.py:gen_pk()`` 生成(DISC.1 落定方案 a)。
* ``indexes`` → ``UNIQUE KEY`` / ``KEY``,列顺序保序(``idxfields`` 数组)。
* ``title`` → ``COMMENT '...'``(带引号)。
用法::
python3 scripts/gen_ddl_from_models.py # 生成 sql/pbl_evidence.sql
python3 scripts/gen_ddl_from_models.py --check # 只校验磁盘产物与 models 一致
python3 scripts/gen_ddl_from_models.py --stdout # 打印不落盘
退出码:0 成功;1 生成物与磁盘不一致(--check)或 models 解析失败。
"""
import argparse
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS_DIR = os.path.join(ROOT, 'models')
SQL_DIR = os.path.join(ROOT, 'sql')
# 表顺序(与模块内数据流一致:先产出物后证据)
TABLE_ORDER = ('pbl_artifact', 'pbl_evidence')
# 抽象类型 → MariaDB 原生类型(str/char/float/double/ddouble/decimal 需带 length/dec)
TYPE_MAP = {
'str': lambda f: 'VARCHAR(%d)' % int(f['length']),
'char': lambda f: 'CHAR(%d)' % int(f['length']),
'short': lambda f: 'SMALLINT',
'int': lambda f: 'INT',
'long': lambda f: 'BIGINT',
'float': lambda f: 'DECIMAL(%d,%d)' % (int(f['length']), int(f['dec'])),
'double': lambda f: 'DECIMAL(%d,%d)' % (int(f['length']), int(f['dec'])),
'ddouble': lambda f: 'DECIMAL(%d,%d)' % (int(f['length']), int(f['dec'])),
'decimal': lambda f: 'DECIMAL(%d,%d)' % (int(f['length']), int(f['dec'])),
'date': lambda f: 'DATE',
'time': lambda f: 'TIME',
'datetime': lambda f: 'DATETIME',
'timestamp': lambda f: 'DATETIME',
'text': lambda f: 'LONGTEXT',
'bin': lambda f: 'LONGBLOB',
}
GEN_BANNER = (
'-- 本文件由 scripts/gen_ddl_from_models.py 自动生成,**单一真源 = models/*.json**。\n'
'-- 禁止手工编辑本文件(改表结构请改 models/{table}.json 后重跑本脚本)。\n'
'-- 生成命令:python3 scripts/gen_ddl_from_models.py\n'
'--\n'
'-- M5a-DISC.1 主键口径(models / DDL / collector 三方对齐):\n'
'-- id = VARCHAR(32) NOT NULL,无 AUTO_INCREMENT、无 DEFAULT;\n'
'-- 主键值由应用层 pbl_evidence/pk.py:gen_pk()(appPublic.uniqueID.getID,\n'
'-- 离线降级 uuid4().hex,长度均 <= 32)在 INSERT 前生成并显式写入列集合。\n'
'-- 引用字段 tenant_id/artifact_id/session_id/blueprint_id/learner_id/\n'
'-- source_event_id/dedup_key/collect_batch_id 一律 VARCHAR(32),与主键同型,\n'
'-- JOIN / 等值比较不存在类型不匹配。\n'
)
def _q(text):
"""SQL 单引号字面量(转义内部单引号,去掉换行避免破坏 DDL 行结构)。"""
s = str(text if text is not None else '')
s = s.replace('\\', '\\\\').replace("'", "''")
s = s.replace('\r', ' ').replace('\n', ' ')
return "'%s'" % s
def load_model(table):
path = os.path.join(MODELS_DIR, '%s.json' % table)
if not os.path.isfile(path):
raise RuntimeError('缺少表定义 %s' % path)
with open(path, 'r', encoding='utf-8') as fh:
model = json.load(fh)
for section in ('summary', 'fields'):
if not model.get(section):
raise RuntimeError('%s: 四段式缺少/空 %s 段' % (table, section))
return model
def render_column(field):
name = field['name']
ftype = (field.get('type') or '').strip()
if ftype not in TYPE_MAP:
raise RuntimeError('%s: 未支持抽象类型 %r' % (name, ftype))
spec = TYPE_MAP[ftype](field)
parts = [' `%s` %s' % (name, spec)]
# NOT NULL 只判定一次(nullable 必须是字符串 'no',其他写法按可空处理)
parts.append('NOT NULL' if field.get('nullable') == 'no' else 'NULL')
if 'default' in field and field['default'] is not None:
dv = field['default']
if isinstance(dv, bool):
dv = int(dv)
if isinstance(dv, (int, float)):
parts.append('DEFAULT %s' % dv)
elif ftype in ('int', 'long', 'short', 'float', 'double', 'ddouble', 'decimal') \
and str(dv).replace('.', '', 1).replace('-', '', 1).isdigit():
parts.append('DEFAULT %s' % dv)
else:
parts.append('DEFAULT %s' % _q(dv))
if field.get('title'):
parts.append('COMMENT %s' % _q(field['title']))
return ' '.join(parts)
def render_table(table):
model = load_model(table)
summary = model['summary'][0]
pk = summary.get('primary') or []
if not isinstance(pk, list) or not pk:
raise RuntimeError('%s: summary[0].primary 必须是非空数组' % table)
fields = model['fields']
names = [f['name'] for f in fields]
missing = [c for c in pk if c not in names]
if missing:
raise RuntimeError('%s: 主键列不在 fields 中: %s' % (table, missing))
lines = ['CREATE TABLE IF NOT EXISTS `%s` (' % table]
body = [render_column(f) for f in fields]
body.append(' PRIMARY KEY (%s)' % ', '.join('`%s`' % c for c in pk))
for idx in (model.get('indexes') or []):
idxfields = idx.get('idxfields')
if not isinstance(idxfields, list) or not idxfields:
raise RuntimeError('%s: 索引 %s 的 idxfields 必须是非空数组' % (table, idx.get('name')))
bad = [c for c in idxfields if c not in names]
if bad:
raise RuntimeError('%s: 索引 %s 引用了不存在的列 %s' % (table, idx.get('name'), bad))
kw = 'UNIQUE KEY' if idx.get('idxtype') == 'unique' else 'KEY'
body.append(' %s `%s` (%s)' % (kw, idx.get('name'),
', '.join('`%s`' % c for c in idxfields)))
lines.append(',\n'.join(body))
lines.append(') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT=%s;'
% _q(summary.get('title') or table))
return '\n'.join(lines)
def build_sql(tables=TABLE_ORDER):
chunks = [GEN_BANNER]
for t in tables:
chunks.append('-- 表 %s(真源 models/%s.json)' % (t, t))
chunks.append(render_table(t))
chunks.append('')
return '\n'.join(chunks).rstrip() + '\n'
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--check', action='store_true', help='只比对磁盘产物,不写文件')
ap.add_argument('--stdout', action='store_true', help='打印到标准输出,不写文件')
ap.add_argument('--out', default=os.path.join(SQL_DIR, 'pbl_evidence.sql'))
args = ap.parse_args()
try:
sql = build_sql()
except Exception as exc:
print('GEN DDL FAILED: %s' % exc)
return 1
if args.stdout:
sys.stdout.write(sql)
return 0
old = ''
if os.path.isfile(args.out):
with open(args.out, 'r', encoding='utf-8') as fh:
old = fh.read()
if args.check:
if old == sql:
print('[PASS] %s 与 models/*.json 再生成结果一致(%d 字节)'
% (os.path.relpath(args.out, ROOT).replace(os.sep, '/'), len(sql)))
return 0
print('[FAIL] %s 与 models 再生成结果不一致(磁盘 %d 字节 / 应为 %d 字节)'
% (os.path.relpath(args.out, ROOT).replace(os.sep, '/'), len(old), len(sql)))
return 1
d = os.path.dirname(args.out)
if not os.path.isdir(d):
os.makedirs(d)
with open(args.out, 'w', encoding='utf-8') as fh:
fh.write(sql)
print('[OK] 已生成 %s(%d 字节,真源 models/%s.json)'
% (os.path.relpath(args.out, ROOT).replace(os.sep, '/'), len(sql), '/'.join(TABLE_ORDER)))
return 0
if __name__ == '__main__':
sys.exit(main())