#!/usr/bin/env python3 # -*- coding: utf-8 -*- """pbl_blueprint 派生物生成器(幂等)。 输入:pbl_blueprint/models/{tblname}.json —— 四段式表定义(summary/fields/indexes/codes) 输出: 1) pbl_blueprint/json/{tblname}.json —— CRUD 契约定义(根键 = tblname + params(editable/browserfields)) 2) pbl_blueprint/sql/pbl_blueprint.core.sql —— DDL 派生物(engine=postgresql,禁止手工维护) 用法:python3 scripts/derive_artifacts.py QC 核对点:json/*.json 与 sql/*.sql 均由本脚本机械派生,改表只需改 models/*.json 后重跑本脚本。 """ import json import os import glob HERE = os.path.dirname(os.path.abspath(__file__)) PKG = os.path.join(os.path.dirname(HERE), 'pbl_blueprint') MODELS_DIR = os.path.join(PKG, 'models') JSON_DIR = os.path.join(PKG, 'json') SQL_DIR = os.path.join(PKG, 'sql') # 表 -> dspy 接口前缀(CRUD editable url 用) API_PREFIX = { 'pbl_blueprint': 'blueprint', 'pbl_blueprint_version': 'version', 'pbl_blueprint_template': 'template', 'pbl_blueprint_template_item': 'template_item', } BROWSER_FIELDS = ('id', 'tenant_id', 'class_id', 'code', 'name', 'blueprint_code', 'blueprint_name', 'template_code', 'template_name', 'status', 'version_status', 'version_no', 'subobject_type', 'domain_code', 'grade_code', 'subject_code', 'complexity_level', 'seq_no', 'created_at', 'updated_at') HEAVY_FIELDS = ('snapshot_json', 'content_json', 'item_payload_json', 'quality_criteria', 'knowledge_points', 'tags_json') IMMUTABLE = ('id', 'tenant_id', 'created_by', 'created_at') def api_prefix(tbl): if tbl in API_PREFIX: return API_PREFIX[tbl] return 'subobject' def load_models(): docs = {} for p in sorted(glob.glob(os.path.join(MODELS_DIR, '*.json'))): tbl = os.path.basename(p)[:-5] doc = json.load(open(p, encoding='utf-8')) for seg in ('summary', 'fields', 'indexes', 'codes'): if seg not in doc: raise SystemExit('models/%s.json 缺四段式必需段: %s' % (tbl, seg)) if not isinstance(doc['fields'], dict): raise SystemExit('models/%s.json fields 必须是对象(列名->定义)' % tbl) docs[tbl] = doc return docs def build_crud(tbl, doc): fields = doc['fields'] names = list(fields.keys()) editable = [n for n in names if n not in IMMUTABLE] browser = [n for n in names if n in BROWSER_FIELDS] alters = {} for n, spec in fields.items(): if spec.get('appcode'): alters[n] = {'uitype': 'code', 'appcode': spec['appcode'], 'label': spec.get('label', n)} a = api_prefix(tbl) return {tbl: { 'summary': doc['summary'].get('label', tbl), 'alias': tbl + '_crud', 'params': { 'sortby': 'created_at', 'sortorder': 'desc', 'tenant_scoped': True, 'browserfields': { 'fields': browser, 'alters': alters, 'exclouded': list(HEAVY_FIELDS), }, 'editable': { 'new_data_url': "{{entire_url('../api/%s_save.dspy')}}" % a, 'update_data_url': "{{entire_url('../api/%s_save.dspy')}}" % a, 'delete_data_url': "{{entire_url('../api/%s_delete.dspy')}}" % a, 'list_data_url': "{{entire_url('../api/%s_list.dspy')}}" % a, 'get_data_url': "{{entire_url('../api/%s_get.dspy')}}" % a, 'editexclouded': list(IMMUTABLE), }, 'required': [n for n, s in fields.items() if s.get('required')], 'data_filter': {'AND': [{'field': 'tenant_id', 'op': '=', 'var': 'tenant_id'}]}, }, 'codes': doc['codes'], 'table_meta': { 'pk': 'id', 'tenant_field': 'tenant_id', 'soft_delete_field': 'is_deleted', 'engine': doc['summary'].get('engine', 'postgresql'), 'indexes': list(doc['indexes'].keys()), }, }} def col_ddl(name, spec): ty = spec['type'] if ty.startswith('str'): return '%s varchar(%s)' % (name, ty[ty.index('(') + 1:ty.index(')')]) if ty == 'text': return '%s text' % name if ty == 'int': return '%s integer' % name if ty.startswith('double'): return '%s numeric(18,2)' % name if ty == 'datetime': return '%s timestamp' % name raise SystemExit('未知抽象类型: %s (%s)' % (ty, name)) def build_sql(docs): lines = [ '-- pbl_blueprint 核心表 DDL', '-- 本文件由 scripts/derive_artifacts.py 从 pbl_blueprint/models/*.json 机械派生,禁止手工编辑', '-- engine: postgresql(与 projects/pbls/env/*.json 的 db.engine 一致)', '-- 主键 id 为应用层生成的 str(32):不使用 SERIAL / BIGSERIAL / nextval / AUTO_INCREMENT', '', ] for tbl in sorted(docs.keys()): doc = docs[tbl] fields = doc['fields'] lines.append('-- %s: %s' % (tbl, doc['summary'].get('label', ''))) cols = ',\n'.join( ' ' + col_ddl(k, v) + (' NOT NULL' if (v.get('required') or k == 'id') else '') for k, v in fields.items()) lines.append('CREATE TABLE IF NOT EXISTS %s (' % tbl) lines.append(cols) lines.append(');') for k, v in fields.items(): lines.append("COMMENT ON COLUMN %s.%s IS '%s';" % (tbl, k, v.get('label', ''))) lines.append('') for iname, isp in doc['indexes'].items(): if iname == 'primary': continue uniq = 'UNIQUE ' if isp.get('unique') else '' lines.append('CREATE %sINDEX IF NOT EXISTS %s_%s ON %s (%s);' % (uniq, tbl, iname, tbl, ', '.join(isp['fields']))) lines.append('') return '\n'.join(lines) def main(): docs = load_models() os.makedirs(JSON_DIR, exist_ok=True) os.makedirs(SQL_DIR, exist_ok=True) for tbl, doc in docs.items(): out = os.path.join(JSON_DIR, '%s.json' % tbl) with open(out, 'w', encoding='utf-8') as fp: json.dump(build_crud(tbl, doc), fp, ensure_ascii=False, indent=2) with open(os.path.join(SQL_DIR, 'pbl_blueprint.core.sql'), 'w', encoding='utf-8') as fp: fp.write(build_sql(docs)) print('models=%d json=%d sql_tables=%d' % (len(docs), len(glob.glob(os.path.join(JSON_DIR, '*.json'))), len(docs))) if __name__ == '__main__': main()