政务低代码平台实战①:5张表描述任意SQL——ea01-ea05元数据引擎设计
文章目录
- 政务低代码平台实战①:5张表描述任意SQL——ea01-ea05元数据引擎设计
- 背景
- 5张表的结构
- ea01:SQL语句头
- ea02:FROM表
- ea03:列/字段
- ea04:JOIN条件
- ea05:WHERE条件
- commonSql:从元数据拼装SQL的核心类
- SELECT拼装详解
- WHERE条件的智能处理
- Oracle vs SQL Server方言
- system_cache:元数据缓存
- 一个完整示例
- 为什么不用MyBatis的动态SQL
- 决策原则
非科班野生程序员,深耕政务信息化20年。政务系统有几百张业务表,每张表都要查询、新增、修改、删除——如果每条SQL都手写,维护成本爆炸。我的做法是用5张关系表描述一条完整的SQL,运行时动态拼装。90%的数据操作不用写一行SQL,剩下10%的复杂报表留了自定义SQL的口子。这篇拆解这个元数据引擎的设计。最后感谢豆包、智谱、OpenCode,决策是我做的,代码是我搓的,文字是他们总结的。
背景
政务系统有两种数据访问:
- 标准CRUD— 单表或两三张表关联的增删改查,占90%
- 复杂报表— 五六张表关联、子查询、聚合,占10%
MyBatis的常规做法是每个SQL写一个mapper方法 + 一段XML。问题是:一个有50张表的政务系统,SELECT/INSERT/UPDATE/DELETE各一套,就是200段XML。字段改了?XML跟着改。表名改了?到处找。
我的做法:把SQL的结构拆成5张关系表存起来,运行时用一个类动态拼装。
5张表的结构
ea01:SQL语句头
一条SQL的身份证。ea01决定了这条SQL是SELECT还是INSERT还是UPDATE还是DELETE。
| 字段 | 含义 | 示例 |
|---|---|---|
| eae001 | SQL唯一编号 | T_LEAVE_s |
| eae004 | SQL类型 | 1=SELECT,2=UPDATE,3=DELETE,4=INSERT,5=存储过程 |
| eae800 | 是否自定义SQL | 1=自定义, 空=元数据拼装 |
| eae801 | 自定义SQL内容(当eae800=1时使用) | select * from ... |
| eae994 | 页面总列宽(用于表单渲染) | 6 |
eae800是个保险阀。90%的SQL走元数据拼装,但遇到五六张表关联的复杂报表,直接在eae801里写原生SQL。自定义SQL很少用,主要是给复杂报表留口子。
ea02:FROM表
SQL的FROM子句。一条SQL可以关联多张表。
| 字段 | 含义 | 示例 |
|---|---|---|
| eae001 | SQL编号 | T_LEAVE_s |
| eae005 | 表名 | T_LEAVE |
| eae006 | 表别名 | a |
拼出来就是from T_LEAVE a, T_DEPT b。
ea03:列/字段
SELECT的字段列表,或INSERT/UPDATE的字段列表。
| 字段 | 含义 | 示例 |
|---|---|---|
| eae001 | SQL编号 | T_LEAVE_s |
| eae006 | 表别名 | a |
| eae007 | 列名 | LEAVE_ID |
| eae008 | 列别名(AS后面的名字) | leave_id |
| eae009 | 数据类型编码 | 1=字符串,2=日期,3=数字,4=日期时间 |
| eae991 | 显示格式 | 2=日期格式化 |
| eae996 | 显示宽度 | 120(px) |
| eae997 | 二级代码编码 | LEAVE_TYPE |
| eae998 | 是否代码项 | 1=是 |
| comments | 中文注释 | 请假类型 |
| eae700 | 是否显示 | 0=隐藏 |
数据类型编码eae009是关键——它决定了SQL里怎么转换类型,也决定了前端用什么控件:
1 → 字符串(Oracle: varchar2, MSSQL: varchar) → 前端 TextBox 2 → 日期 (Oracle: date, MSSQL: date) → 前端 DateTextBox (yyyy-MM-dd) 3 → 数字 (Oracle: number(18,2), MSSQL: decimal)→ 前端 TextBox 4 → 日期时间(Oracle: date, MSSQL: datetime) → 前端 DateTextBox (yyyy-MM-dd HH:mm:ss)ea04:JOIN条件
表与表之间的关联条件。
| 字段 | 含义 | 示例 |
|---|---|---|
| eae001 | SQL编号 | T_LEAVE_s |
| eae006 | 左表别名.列名 | a.DEPT_ID |
| eae007 | — | — |
| eae010 | 右表别名 | b |
| eae011 | 右表列名 | DEPT_ID |
拼出来就是and a.DEPT_ID = b.DEPT_ID。用的是等值连接,放在WHERE里(不是JOIN ON)。政务系统的关联大多数是主外键等值连接,够用了。
ea05:WHERE条件
查询条件。这是最灵活的部分——支持常量和变量、等于和LIKE、括号。
| 字段 | 含义 | 示例 |
|---|---|---|
| eae001 | SQL编号 | T_LEAVE_s |
| eae006 | 表别名 | a |
| eae007 | 列名 | PROC_INST_ID_ |
| eae009 | 数据类型编码 | 1 |
| eae012 | 关系符 | 01=等于,02=LIKE |
| eae013 | 常量/变量标识 | 1=常量, 空=变量 |
| eae014 | 常量值或变量名 | proc_inst_id_ |
| eae015 | 逻辑连接符 | and/or |
| eae016 | 左括号 | 1=加左括号 |
| eae017 | 右括号 | 1=加右括号 |
eae013是关键区分:
eae013=1(常量):直接拼到SQL里,如and a.AAE100 = '1'(有效标志)eae013为空(变量):用?占位,运行时从前端参数取值
commonSql:从元数据拼装SQL的核心类
commonSql是整个引擎的心脏。2100多行代码,核心就6个方法:
| 方法 | 功能 | 生成什么 |
|---|---|---|
get() | 拼SELECT | select ... from ... where ... |
getCountSql() | 拼COUNT | select count(*) from ... where ... |
update() | 拼UPDATE | update ... set ... where ... |
insert() | 拼INSERT | insert into ... values (...) |
delete() | 拼DELETE | delete from ... where ... |
selectSQL() | 拼装+执行 | 完整的查询流程 |
SELECT拼装详解
以get()方法为例,展示元数据怎么变成SQL:
// 第一步:读缓存List<ea01Dao>resultea01=system_cache.get1(eae001+"-ea01");List<ea02Dao>resultea02=system_cache.get2(eae001+"-ea02");List<ea03Dao>resultea03=system_cache.get3(eae001+"-ea03");List<ea04Dao>resultea04=system_cache.get4(eae001+"-ea04");List<ea05Dao>resultea05=system_cache.get5(eae001+"-ea05");5次缓存读取,拿到一条SQL的全部骨架。
// 第二步:判断是否自定义SQLif("1".equals(resultea01.get(0).getEae800())){// 直接走自定义SQL,不从元数据拼returnselectSQLcustom(id,resultea01.get(0).getEae801(),map,dsName);}// 第三步:拼SELECT子句sql.append("select \n");for(inti=0;i<resultea03.size();i++){// 日期类型要加类型转换if("2".equals(resultea03.get(i).getEae009())){if("mssql".equals(dialect)){sql.append("CONVERT(varchar,");}else{sql.append("to_char(");}}sql.append(resultea03.get(i).getEae006()+"."+resultea03.get(i).getEae007());if("2".equals(resultea03.get(i).getEae009())){if("mssql".equals(dialect)){sql.append(",112)");// MSSQL日期格式}else{sql.append(",'yyyymmdd')");// Oracle日期格式}}sql.append(" as "+resultea03.get(i).getEae008());}拼出来的SQL长这样:
selectto_char(a.LEAVE_DATE,'yyyymmdd')asleave_date,a.LEAVE_TYPEasleave_type,a.LEAVE_DAYSasleave_daysfromT_LEAVE a,T_DEPT bwhere1=1anda.DEPT_ID=b.DEPT_IDanda.PROC_INST_ID_=?anda.AAE100='1'WHERE条件的智能处理
WHERE条件不是全部拼上去,而是有条件地拼:
for(inti=0;i<resultea05.size();i++){if("1".equals(resultea05.get(i).getEae013())// 常量:始终拼||(map.get(resultea05.get(i).getEae014())!=null// 变量:有值才拼&&!"".equals(map.get(resultea05.get(i).getEae014())))){// 拼条件...}}这意味着:如果前端没传某个查询参数,对应的WHERE条件自动消失。不需要前端传"查询所有"的标志,参数为空就不加条件。
Oracle vs SQL Server方言
两种数据库的差异集中在三个地方:
日期转换:
// Oracleto_char(a.LEAVE_DATE,'yyyymmdd')to_date(?,'yyyy-mm-dd')// MSSQLCONVERT(varchar,a.LEAVE_DATE,112)CONVERT(date,?)LIKE拼接:
// Oracle: 用 ||sql.append("||'%'");// MSSQL: 用 +sql.append("+'%'");分页:
// Oracle: rownum"select * from (select row_1.*, rownum as rownum_ from ("+sql+") row_1) row_ where row_.rownum_ > ? and row_.rownum_ <= ?"// MSSQL: row_number() over"select * from(select cte1.*,row_number() over (order by "+orderBy+" desc) rownum_ from("+sql+") as cte1) as cte where rownum_ > ? and rownum_ <= ?"方言判断靠一个全局变量myDbProvider.getDialect(),运行时根据配置决定。所有SQL拼装的地方都做了方言分支。
system_cache:元数据缓存
元数据不每次查数据库,启动时全加载到内存:
publicclasssystem_cache{privatestaticHashMap<String,List<ea01Dao>>cache1=newHashMap<>();// ea01privatestaticHashMap<String,List<ea02Dao>>cache2=newHashMap<>();// ea02privatestaticHashMap<String,List<ea03Dao>>cache3=newHashMap<>();// ea03privatestaticHashMap<String,List<ea04Dao>>cache4=newHashMap<>();// ea04privatestaticHashMap<String,List<ea05Dao>>cache5=newHashMap<>();// ea05publicstaticvoidinit(){// 启动时从数据库全量加载所有SQL元数据// key格式: "sqlId-ea01", "sqlId-ea02", ...}publicstaticvoidreset(){// 清空缓存并重新加载cache1.clear();cache2.clear();cache3.clear();cache4.clear();cache5.clear();init();}}5个HashMap,key是sqlId-表名,value是DAO列表。commonSql每次拼SQL都直接读缓存,零数据库访问。
DDL引擎建完新表后会调system_cache.reset()刷新缓存,新表的元数据立即可用。
一个完整示例
假设要配置一张请假表的查询:
ea01(语句头):
| eae001 | eae004 | eae800 |
|---|---|---|
| T_LEAVE_s | 1 (SELECT) | (空,走元数据) |
ea02(FROM表):
| eae005 | eae006 |
|---|---|
| T_LEAVE | a |
ea03(列):
| eae006 | eae007 | eae008 | eae009 | comments |
|---|---|---|---|---|
| a | LEAVE_ID | leave_id | 1 | 请假编号 |
| a | LEAVE_DATE | leave_date | 2 | 请假日期 |
| a | LEAVE_TYPE | leave_type | 1 | 请假类型 |
| a | LEAVE_DAYS | leave_days | 3 | 请假天数 |
ea04(JOIN条件):无(单表查询)
ea05(WHERE条件):
| eae006 | eae007 | eae012 | eae013 | eae014 | eae015 |
|---|---|---|---|---|---|
| a | PROC_INST_ID_ | 01 | proc_inst_id_ | and | |
| a | AAE100 | 01 | 1 | 1 | and |
前端传sqlId=T_LEAVE_s&proc_inst_id_=12345,后端自动拼出:
selecta.LEAVE_IDasleave_id,to_char(a.LEAVE_DATE,'yyyymmdd')asleave_date,a.LEAVE_TYPEasleave_type,a.LEAVE_DAYSasleave_daysfromT_LEAVE awhere1=1anda.PROC_INST_ID_=?anda.AAE100='1'参数12345通过PreparedStatement绑定到第一个?。
为什么不用MyBatis的动态SQL
MyBatis有<if>、<where>、<foreach>等动态SQL标签,能实现类似的条件拼装。区别在于:
| MyBatis动态SQL | ea01-ea05元数据 | |
|---|---|---|
| SQL存在哪 | XML文件 | 数据库表 |
| 谁维护 | 开发人员 | 开发人员或管理界面 |
| 改了要重启 | 不用(MyBatis可以热加载) | 不用(清缓存即可) |
| 新增查询要写代码 | 要 | 不要(插几行数据) |
| 方言切换 | 要写两套XML | 自动切换 |
| 前端表单联动 | 需要额外配置 | ea03自带控件类型和宽度 |
核心差异是元数据在前端也能用——ea03的字段注释、宽度、代码项标识,查询页面和表单页面都要用。如果用XML存SQL,这些信息得在另一个地方再存一份。ea01-ea05一份数据,SQL拼装和前端渲染都用。
决策原则
把SQL的结构从代码移到数据。
SQL的字段会变(加个字段)、条件会变(换个查询条件)、关联会变(多关联一张表)。这些东西不应该散落在几十个XML文件里。用关系表结构化地描述它们,一个类统一拼装,新增一条查询就是插几行数据的事。
自定义SQL(eae800=1)是保险阀——大部分场景走元数据,复杂报表直接写原生SQL,两条路都能到。但实际用得很少,90%都是元数据拼装。
如果你的系统也有大量重复的CRUD操作,可以考虑用元数据描述SQL。欢迎评论区聊聊你的做法。
系列导航:
- 总纲:[政务低代码平台实战——从元数据引擎到可视化设计器的五个关键决策]
- 上一篇:(总纲)
- 下一篇:[政务低代码平台实战②:运行时DDL引擎,前端拖完字段后端直接建]
作者:许彰午| 非科班野生程序员,深耕政务信息化20年
标签:#Java #低代码 #元数据驱动 #动态SQL #Oracle #SQLServer #政务信息化