pbl_blueprint/tools/m1b_gen_ddl.py
2026-09-17 15:16:08 +08:00

402 lines
19 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 -*-
"""M1b DDL 生成器:由 m1b/tables.py 单一事实来源产出双方言 DDL + models JSON。
对应 QC 退回意见 #5/#7需给出字段/表/关联关系映射 + DDL 产物 + 落库证据。
产物(全部写入仓库,可复核):
modules/pbl_blueprint/pbl_blueprint/sql/m1b_ddl.sql mariadb 方言(生产)
modules/pbl_blueprint/pbl_blueprint/sql/m1b_ddl_sqlite.sql sqlite 方言(取证/测试)
modules/pbl_blueprint/pbl_blueprint/models/m1b/*.json 4 个表定义(四段式)
modules/pbl_blueprint/pbl_blueprint/json/m1b/pbl_blueprint_template.json 离线模板包
projects/pbls/deliverables/m1b/ddl_report.json 生成报告(字段/索引统计)
用法:
python3 modules/pbl_blueprint/tools/m1b_gen_ddl.py # 生成全部产物
python3 modules/pbl_blueprint/tools/m1b_gen_ddl.py --check # 只校验一致性,不写盘
一致性保证mariadb DDL、sqlite DDL、models JSON 三者都由同一份 TABLES 生成,
字段名/顺序/类型语义一一对应;--check 模式会逐表逐字段比对 models JSON 与
TABLES任何漂移都返回非 0 退出码。
"""
import argparse
import json
import os
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE) # modules/pbl_blueprint
PKG = os.path.join(MOD_ROOT, "pbl_blueprint")
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
if MOD_ROOT not in sys.path:
sys.path.insert(0, MOD_ROOT)
from pbl_blueprint.m1b.tables import TABLES, to_model_json # noqa: E402
from pbl_blueprint.m1b.init import build_sqlite_ddl # noqa: E402
SQL_DIR = os.path.join(PKG, "sql")
MODELS_DIR = os.path.join(PKG, "models", "m1b")
JSON_DIR = os.path.join(PKG, "json", "m1b")
REPORT_DIR = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables", "m1b")
#: 抽象类型 -> mariadb 类型
MYSQL_TYPES = {
"text": "LONGTEXT",
"json": "LONGTEXT",
"int": "BIGINT",
"bool": "TINYINT(1)",
"datetime": "VARCHAR(32)",
"float": "DECIMAL(18,4)",
}
def mysql_type(field):
"""抽象类型 -> mariadb 列类型string(N) -> VARCHAR(N))。"""
raw = field.get("type") or "string(255)"
base = raw.split("(")[0].strip().lower()
if base == "string":
size = raw.split("(")[1].rstrip(")") if "(" in raw else "255"
return "VARCHAR(%s)" % size
return MYSQL_TYPES.get(base, "VARCHAR(255)")
def gen_mysql_ddl(tables=None):
"""生成 mariadb 方言 DDLInnoDB / utf8mb4 / 无 FOREIGN KEYQ-OPEN-3"""
out = [
"-- ============================================================",
"-- PBL M1b DDL (mariadb dialect) - generated by tools/m1b_gen_ddl.py",
"-- Source of truth: pbl_blueprint/m1b/tables.py",
"-- Q-OPEN-3: NO FOREIGN KEY; base tables (world/scene/entity/script)",
"-- are NEVER created/altered here (read-only soft refs).",
"-- Idempotent: CREATE TABLE IF NOT EXISTS / CREATE INDEX guarded.",
"-- ============================================================",
"",
]
idx_stmts = []
for t in (tables or TABLES):
cols = []
for f in t["fields"]:
if f.get("pk"):
cols.append(" `%s` VARCHAR(40) NOT NULL COMMENT '%s'"
% (f["name"], _esc(f.get("comment"))))
continue
notnull = " NOT NULL" if f.get("required") else " NULL"
default = ""
if f["name"] == "is_deleted":
default = " DEFAULT 0"
elif f["name"] == "enabled":
default = " DEFAULT 1"
elif f["name"] == "resolve_status":
default = " DEFAULT 'unresolved'"
elif f["name"] == "ownership":
default = " DEFAULT 'read_only'"
elif f["name"] == "tenant_key":
default = " DEFAULT '__tenant__'"
cols.append(" `%s` %s%s%s COMMENT '%s'" % (
f["name"], mysql_type(f), notnull, default, _esc(f.get("comment"))))
pks = [f["name"] for f in t["fields"] if f.get("pk")]
if pks:
cols.append(" PRIMARY KEY (`%s`)" % "`, `".join(pks))
out.append("-- %s" % t["summary"].replace("\n", " "))
out.append("CREATE TABLE IF NOT EXISTS `%s` (" % t["name"])
out.append(",\n".join(cols))
out.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "
"COLLATE=utf8mb4_general_ci COMMENT='%s';" % _esc(t["summary"][:60]))
out.append("")
for ix in t["indexes"]:
kw = "UNIQUE INDEX" if ix.get("unique") else "INDEX"
idx_stmts.append(
"-- %s\nALTER TABLE `%s` ADD %s `%s` (%s);" % (
ix.get("comment", ""), t["name"], kw, ix["name"],
", ".join(["`%s`" % c for c in ix["columns"]])))
out.append("-- ---------- indexes (MySQL 不支持 CREATE INDEX IF NOT EXISTS")
out.append("-- 重复执行会报 1061 Duplicate key name可忽略")
out.append("-- 首次建库执行本段即可) ----------")
out.extend(idx_stmts)
out.append("")
return "\n".join(out)
def _esc(text):
"""SQL 注释里的单引号转义。"""
return (text or "").replace("'", "''").replace("\n", " ")
OFFLINE_TEMPLATES = {
"templates": [
{
"id": "tpl_platform_pbl_stem_v1",
"tenant_id": None,
"tenant_key": "__platform__",
"code": "platform.pbl.stem",
"name": "平台公共·STEM 项目式学习模板",
"description": "平台公共模板tenant_id NULL租户只读STEM 主题 PBL 骨架,"
"含 7 类子对象默认结构与扩展字段默认值。",
"category": "STEM",
"scope": "platform",
"version": "1.0.0",
"status": "published",
"source": "offline_fallback",
"tags": ["platform", "stem", "public"],
"ext_schema": {
"difficulty": {"type": "enum", "options": ["入门", "进阶", "高阶"],
"default": "进阶"},
"duration_hours": {"type": "int", "default": 12, "min": 1, "max": 200},
"subject": {"type": "string", "default": "综合实践"},
},
"default_values": {
"difficulty": "进阶",
"duration_hours": 12,
"subject": "综合实践",
"assessment_mode": "rubric",
},
"subobject_policy": {
"driving_question": {"min": 1, "default_item": {
"question": "我们如何用工程方法解决身边的真实问题?",
"cognitive_level": "应用"}},
"learning_goal": {"min": 2, "default_item": {
"goal": "能运用跨学科知识完成一个可交付的作品",
"bloom_level": "L3", "assessable": True}},
"mission": {"min": 3, "default_item": {
"name": "阶段任务", "estimated_minutes": 45}},
"problem": {"min": 1, "default_item": {
"title": "核心问题", "difficulty": ""}},
"role": {"min": 2, "default_item": {
"name": "团队成员", "team_size": 4}},
"learner": {"min": 0, "default_item": {"name": "学习者"}},
"artifact_def": {"min": 1, "default_item": {
"name": "作品与报告", "artifact_type": "模型"}},
},
"structure": {
"blueprint": {"name": "STEM 项目式学习(模板实例)",
"status": "draft", "category": "STEM"},
"subobjects": {
"driving_question": [
{"question": "我们如何用工程方法解决身边的真实问题?",
"sort_no": 1, "ext": {"cognitive_level": "应用",
"weight": 0.4}},
],
"learning_goal": [
{"goal": "掌握需求分析与方案设计流程", "sort_no": 1,
"ext": {"bloom_level": "L3", "assessable": True}},
{"goal": "完成可演示的原型作品", "sort_no": 2,
"ext": {"bloom_level": "L4", "assessable": True}},
],
"mission": [
{"name": "M1 需求调研", "sort_no": 1,
"ext": {"estimated_minutes": 45}},
{"name": "M2 方案设计", "sort_no": 2,
"ext": {"estimated_minutes": 90}},
{"name": "M3 原型实现与展示", "sort_no": 3,
"ext": {"estimated_minutes": 120}},
],
"problem": [
{"title": "如何在有限材料下提升结构强度?", "sort_no": 1,
"ext": {"difficulty": ""}},
],
"role": [
{"name": "项目经理", "sort_no": 1,
"ext": {"team_size": 1, "responsibility": "统筹与汇报"}},
{"name": "工程师", "sort_no": 2,
"ext": {"team_size": 3, "responsibility": "设计与实现"}},
],
"learner": [],
"artifact_def": [
{"name": "设计文档", "sort_no": 1,
"ext": {"artifact_type": "文档"}},
{"name": "原型作品", "sort_no": 2,
"ext": {"artifact_type": "模型"}},
],
},
},
},
{
"id": "tpl_platform_pbl_humanities_v1",
"tenant_id": None,
"tenant_key": "__platform__",
"code": "platform.pbl.humanities",
"name": "平台公共·人文社科探究模板",
"description": "平台公共模板tenant_id NULL人文社科主题探究骨架。",
"category": "人文社科",
"scope": "platform",
"version": "1.0.0",
"status": "published",
"source": "offline_fallback",
"tags": ["platform", "humanities", "public"],
"ext_schema": {
"difficulty": {"type": "enum", "options": ["入门", "进阶", "高阶"],
"default": "入门"},
"duration_hours": {"type": "int", "default": 8},
"subject": {"type": "string", "default": "社会"},
},
"default_values": {"difficulty": "入门", "duration_hours": 8,
"subject": "社会", "assessment_mode": "peer"},
"subobject_policy": {
"driving_question": {"min": 1, "default_item": {
"question": "社区中的公共议题如何影响我们的生活?"}},
"learning_goal": {"min": 1, "default_item": {
"goal": "能用证据支持自己的观点"}},
"mission": {"min": 2, "default_item": {"name": "探究阶段"}},
"problem": {"min": 1, "default_item": {"title": "议题界定"}},
"role": {"min": 1, "default_item": {"name": "调研员"}},
"learner": {"min": 0, "default_item": {"name": "学习者"}},
"artifact_def": {"min": 1, "default_item": {"name": "调研报告"}},
},
"structure": {
"blueprint": {"name": "人文社科探究(模板实例)", "status": "draft",
"category": "人文社科"},
"subobjects": {
"driving_question": [
{"question": "社区中的公共议题如何影响我们的生活?",
"sort_no": 1}],
"learning_goal": [
{"goal": "能用证据支持自己的观点", "sort_no": 1}],
"mission": [{"name": "M1 议题选择", "sort_no": 1},
{"name": "M2 田野调查与汇报", "sort_no": 2}],
"problem": [{"title": "如何界定一个可研究的公共议题?",
"sort_no": 1}],
"role": [{"name": "调研员", "sort_no": 1},
{"name": "记录员", "sort_no": 2}],
"learner": [],
"artifact_def": [{"name": "调研报告", "sort_no": 1},
{"name": "公开展示", "sort_no": 2}],
},
},
},
]
}
def write(path, text):
"""写文件(自动建目录),返回字节数。"""
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
return len(text.encode("utf-8"))
def check_models_consistency():
"""校验 models/m1b/*.json 与 TABLES 一致(字段名/顺序/索引/唯一键)。"""
problems = []
for t in TABLES:
path = os.path.join(MODELS_DIR, "%s.json" % t["name"])
if not os.path.exists(path):
problems.append({"table": t["name"], "code": "MODEL_MISSING",
"path": path})
continue
with open(path, "r", encoding="utf-8") as fh:
try:
model = json.load(fh)
except ValueError as exc:
problems.append({"table": t["name"], "code": "MODEL_INVALID_JSON",
"error": str(exc)})
continue
expect = to_model_json(t["name"])
got_fields = [f["name"] for f in (model.get("fields") or [])]
exp_fields = [f["name"] for f in expect["fields"]]
if got_fields != exp_fields:
problems.append({"table": t["name"], "code": "FIELD_MISMATCH",
"missing": [f for f in exp_fields if f not in got_fields],
"extra": [f for f in got_fields if f not in exp_fields]})
got_ix = sorted([i["name"] for i in (model.get("indexes") or [])])
exp_ix = sorted([i["name"] for i in expect["indexes"]])
if got_ix != exp_ix:
problems.append({"table": t["name"], "code": "INDEX_MISMATCH",
"got": got_ix, "expect": exp_ix})
for sec in ("summary", "fields", "indexes", "codes"):
if sec not in model:
problems.append({"table": t["name"], "code": "SECTION_MISSING",
"section": sec})
return problems
def main(argv=None):
ap = argparse.ArgumentParser(description="M1b DDL / models generator")
ap.add_argument("--check", action="store_true",
help="只校验 models 与 TABLES 一致性,不写盘")
ap.add_argument("--no-offline", action="store_true",
help="不生成离线模板包")
args = ap.parse_args(argv)
if args.check:
problems = check_models_consistency()
print(json.dumps({"mode": "check", "tables": [t["name"] for t in TABLES],
"problems": problems,
"consistent": not problems}, ensure_ascii=False, indent=2))
return 1 if problems else 0
mysql_ddl = gen_mysql_ddl()
sqlite_ddl = build_sqlite_ddl()
written = []
written.append({"path": os.path.relpath(os.path.join(SQL_DIR, "m1b_ddl.sql"), REPO_ROOT),
"bytes": write(os.path.join(SQL_DIR, "m1b_ddl.sql"), mysql_ddl),
"dialect": "mariadb", "lines": mysql_ddl.count("\n") + 1})
written.append({"path": os.path.relpath(os.path.join(SQL_DIR, "m1b_ddl_sqlite.sql"), REPO_ROOT),
"bytes": write(os.path.join(SQL_DIR, "m1b_ddl_sqlite.sql"), sqlite_ddl),
"dialect": "sqlite", "lines": sqlite_ddl.count("\n") + 1})
for t in TABLES:
p = os.path.join(MODELS_DIR, "%s.json" % t["name"])
txt = json.dumps(to_model_json(t["name"]), ensure_ascii=False, indent=2) + "\n"
written.append({"path": os.path.relpath(p, REPO_ROOT), "bytes": write(p, txt),
"dialect": "model-json", "table": t["name"],
"fields": len(t["fields"]), "indexes": len(t["indexes"])})
if not args.no_offline:
p = os.path.join(JSON_DIR, "pbl_blueprint_template.json")
txt = json.dumps(OFFLINE_TEMPLATES, ensure_ascii=False, indent=2) + "\n"
written.append({"path": os.path.relpath(p, REPO_ROOT), "bytes": write(p, txt),
"dialect": "offline-package",
"templates": len(OFFLINE_TEMPLATES["templates"])})
report = {
"generator": "tools/m1b_gen_ddl.py",
"source_of_truth": "pbl_blueprint/m1b/tables.py",
"tables": [{"name": t["name"], "summary": t["summary"],
"field_count": len(t["fields"]),
"fields": [f["name"] for f in t["fields"]],
"indexes": [{"name": i["name"], "unique": bool(i.get("unique")),
"columns": i["columns"], "comment": i.get("comment", "")}
for i in t["indexes"]],
"codes": t.get("codes") or {}} for t in TABLES],
"q_open_3": {"foreign_keys": 0, "base_tables_touched": [],
"note": "无 FOREIGN KEY不 CREATE/ALTER world/scene/entity/script"},
"unique_keys_tenant_first": all(
(i.get("unique") and i["columns"][0] == "tenant_key")
for t in TABLES for i in t["indexes"]),
"written": written,
"consistency_problems": check_models_consistency(),
}
rp = os.path.join(REPORT_DIR, "ddl_report.json")
write(rp, json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(json.dumps({"ok": True, "written": [w["path"] for w in written],
"report": os.path.relpath(rp, REPO_ROOT),
"tables": [t["name"] for t in TABLES],
"consistency_problems": report["consistency_problems"]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())