diff --git a/scripts/check_crud_json.py b/scripts/check_crud_json.py new file mode 100644 index 0000000..a8067f1 --- /dev/null +++ b/scripts/check_crud_json.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""CRUD 定义(json/*.json)规范自检(QC 退回意见 #4/#5/#6 的 CI 化)。 + +三条硬规则(依据 crud-definition-spec / module-development-spec): +1. **合法键**:`params` 只允许规范定义的键。`order_by` / `page_size` 是**自创键**—— + 排序用 `sortby`(数组),分页由 sqlor `sqlPaging()` 用 ns 的 `page`/`rows` 处理, + 在 CRUD JSON 里硬编码 ORDER BY / 页大小属禁项(且与 sortby 语义冲突)。 +2. **alias 唯一**:alias 只用于「同一张表的多个逻辑视图」。两张**不同**的表共用同一 + alias,xls2ui 会生成到同一个 `wwwroot/{alias}/` 目录互相覆盖。 +3. **文件名**:`json/{table_name}.json` 或 `{alias}.json`。文件名与 tblname/alias 都不 + 匹配时,人和工具都对不上号(曾出现 evidence_pbl_artifact.json 这种两头不靠的命名)。 + +退出码 0=PASS,1=FAIL。不连库、无第三方依赖,可直接放 CI。 +""" +import io +import json +import os +import sys + +ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) +JSON_DIR = os.path.join(ROOT, 'json') + +# crud-definition-spec 中 list/tree 视图 params 的合法键集合 +ALLOWED_PARAM_KEYS = { + 'sortby', 'logined_userorgid', 'logined_userid', 'data_filter', + 'confidential_fields', 'editor', 'browserfields', 'editexclouded', + 'edit_exclouded_fields', 'subtables', 'idField', 'textField', + 'parentField', 'editable', 'uitype', + # editable 三 URL 规范上要求「params 顶层」(xls2ddl 从顶层读),同时兼容 + # 旧写法嵌在 editable 内 —— 两处都允许,但顶层必须存在(见 TOP_LEVEL_URL_KEYS) + 'new_data_url', 'update_data_url', 'delete_data_url', + # 展示/检索辅助键(规范内已文档化的可选项) + 'filter_labels', 'filter_label', 'filter_title', 'data_url', +} +FORBIDDEN_PARAM_KEYS = {'order_by', 'page_size', 'page', 'rows', 'limit', 'offset', + 'fields', 'joins', 'select_fields', 'grid', 'form', 'components'} +TOP_LEVEL_URL_KEYS = ('new_data_url', 'update_data_url', 'delete_data_url') + + +def main(): + problems = [] + alias_map = {} + if not os.path.isdir(JSON_DIR): + print('[pbl_evidence] no json/ directory') + return 1 + + for fn in sorted(os.listdir(JSON_DIR)): + if not fn.endswith('.json'): + continue + rel = 'json/' + fn + path = os.path.join(JSON_DIR, fn) + try: + d = json.loads(io.open(path, encoding='utf-8').read()) + except Exception as exc: + problems.append('%s 不是合法 JSON:%s' % (rel, exc)) + continue + + tbl = d.get('tblname') + if not tbl: + problems.append('%s 缺 tblname(禁 tablename/name/type)' % rel) + alias = d.get('alias') + if alias: + alias_map.setdefault(alias, set()).add(tbl or rel) + + # 规则 3:文件名必须等于 {tblname}.json 或 {alias}.json + stem = fn[:-5] + allowed_names = {tbl, alias} - {None} + # 多逻辑视图约定 alias = {table}_{view},故也接受以 tbl 开头的下划线名 + if stem not in allowed_names and not any( + stem == t or stem.startswith(t + '_') for t in allowed_names): + problems.append('%s 文件名与 tblname=%s / alias=%s 均不匹配' + '(应为 {tblname}.json 或 {alias}.json)' % (rel, tbl, alias)) + + params = d.get('params') + if not isinstance(params, dict) or not params: + problems.append('%s 缺 params 或为空' % rel) + continue + + # 规则 1:合法键 / 禁键 + for key in params: + if key in FORBIDDEN_PARAM_KEYS: + problems.append('%s params.%s 是自创/禁用键(排序用 sortby 数组,' + '分页由 sqlor sqlPaging 用 ns.page/rows 处理)' % (rel, key)) + elif key not in ALLOWED_PARAM_KEYS: + problems.append('%s params.%s 不在 crud-definition-spec 合法键内' % (rel, key)) + + # sortby 必须是数组 + if 'sortby' in params and not isinstance(params['sortby'], list): + problems.append('%s params.sortby 必须是数组' % rel) + + # editable:顶层三 URL 必须齐(xls2ddl 从 params 顶层读,嵌套写法会被静默忽略) + for key in TOP_LEVEL_URL_KEYS: + if key not in params: + problems.append('%s params 顶层缺 %s(嵌套在 editable 内会被 xls2ui 静默忽略,' + '生成默认 add_{table}.dspy)' % (rel, key)) + ed = params.get('editable') + if isinstance(ed, dict): + for key in TOP_LEVEL_URL_KEYS: + if key not in ed: + problems.append('%s params.editable 缺 %s' % (rel, key)) + + # browserfields.alters 的 uitype=code 项必须有 data 或 dataurl + bf = params.get('browserfields') or {} + for fname, spec in (bf.get('alters') or {}).items(): + if not isinstance(spec, dict): + problems.append('%s alters.%s 不是对象' % (rel, fname)) + continue + if spec.get('uitype') != 'code': + problems.append('%s alters.%s uitype 应为 "code"' % (rel, fname)) + if not spec.get('data') and not spec.get('dataurl'): + problems.append('%s alters.%s 既无 data 也无 dataurl' % (rel, fname)) + if spec.get('data_field'): + problems.append('%s alters.%s 用了已废弃的 data_field' % (rel, fname)) + + # 规则 2:alias 不得被两张不同的表共用 + for alias, tbls in sorted(alias_map.items()): + if len(tbls) > 1: + problems.append('alias "%s" 被多张表共用 %s —— xls2ui 会生成到同一 ' + 'wwwroot/%s/ 目录互相覆盖' % (alias, sorted(tbls), alias)) + + for line in problems: + print('FAIL ' + line) + print('[pbl_evidence] crud json: %s (%d 项问题)' + % ('PASS' if not problems else 'FAIL', len(problems))) + return 0 if not problems else 1 + + +if __name__ == '__main__': + sys.exit(main())