
11-rawSql模式#{}占位符替换与参数收集五表元数据覆盖不了所有SQL——复杂统计、多层子查询、UNION。EA01的rawSql字段是逃生舱口整条SQL存进元数据引擎只做三件事——#{param}换成?、按出现顺序收集参数、方言分页包装。这篇拆buildFromRawSql的46行和extractParamNames的正则。文章目录11-rawSql模式#{}占位符替换与参数收集一、rawSql存在的意义二、buildFromRawSql的46行三、extractParamNames正则LinkedHashSet去重保序四、null参数的兜底五、和元数据通道的分岔点六、安全边界rawSql是最危险的地方源码browise-metadata/src/main/java/com/browise/ea/core/engine/SqlBuilder.javaL46-76 L514-524一、rawSql存在的意义五表模型的边界第08篇末尾说过不支持子查询、顶层OR、函数条件。真实系统里这些需求不少-- 按月统计各险种征缴额含环比SELECTt.MONTH,t.AMT,(t.AMT-LAG(t.AMT)OVER(ORDERBYt.MONTH))/LAG(t.AMT)OVER(ORDERBYt.MONTH)ASGROWTHFROM(SELECTTO_CHAR(PAY_DATE,yyyymm)ASMONTH,SUM(PAY_AMT)ASAMTFROMIC50WHEREPAY_DATE#{startDate} AND PAY_DATE #{endDate}GROUPBYTO_CHAR(PAY_DATE,yyyymm))t分析函数、子查询、聚合——五表描述不了。存EA01.EAE800标记EAE8011走rawSql通道。二、buildFromRawSql的46行publicBuiltSqlbuildFromRawSql(StringrawSql,MapString,Stringparams,booleanpaginate,StringorderByColumn,IntegerminRow,IntegermaxRow){if(rawSqlnull||rawSql.trim().isEmpty()){thrownewEaException(EaExceptionType.SQL_ERROR,EA01无SQL内容);}// ①提取所有 #{paramName}保持出现顺序去重ListStringparamNamesextractParamNames(rawSql);ListStringparamValuesnewArrayList();// ②全部 #{xxx} 一刀切换成 JDBC的 ?StringpreparedSqlrawSql.replaceAll(#\\{[^}]},?);// ③按出现顺序收集参数值for(Stringname:paramNames){Stringvalparams.get(name);paramValues.add(val!null?val:);}// ④可选分页包装if(paginateminRow!nullmaxRow!null){preparedSqldialect.paginateCustom(preparedSql,orderByColumn);if(dialect.supportsOffset()){paramValues.add(String.valueOf(maxRow-minRow1));paramValues.add(String.valueOf(minRow-1));}else{paramValues.add(String.valueOf(minRow));paramValues.add(String.valueOf(maxRow));}}returnnewBuiltSql(preparedSql,paramValues);}四步提取参数名→全局替换→按序取值→分页包装。三、extractParamNames正则LinkedHashSet去重保序privateListStringextractParamNames(Stringsql){ListStringnamesnewArrayList();SetStringseennewLinkedHashSet();// 保插入序的去重PatternpPattern.compile(#\\{([^}])});// #{后跟非}字符直到}Matchermp.matcher(sql);while(m.find()){Stringnamem.group(1);if(seen.add(name))names.add(name);// 首次出现才进names}returnnames;}两个关键点为什么去重——同一参数出现多次WHEREPAY_DATE#{monthStart} OR BACKUP_DATE #{monthStart}#{monthStart}出现两次→替换成两个?→两个问号都要绑定值。但参数名列表只留一个names去重了——那第二个问号的值从哪来看③的收集逻辑paramValues只按names去重后加了一次这是个刻意的行为还是bug实测会bind失败参数个数不匹配。所以rawSql里同一参数出现多次时必须写成两个不同名的参数WHEREPAY_DATE#{monthStart} OR BACKUP_DATE #{monthStart2}-- 前端传 {monthStart:2026-01-01, monthStart2:2026-01-01}这是rawSql通道的使用约定——管理界面的SQL编辑器有提示。设计上可以改成按?的出现次数重复收集但那要求正则替换和收集同步进行MyBatis的做法当前实现选择了简单。为什么保序——replaceAll把所有#{}按文本位置顺序换成?。JDBC的?按位置绑定。参数值列表的顺序必须和?的出现顺序一致——names保序就是为这个。四、null参数的兜底Stringvalparams.get(name);paramValues.add(val!null?val:);// 没传→空字符串动态条件可以跳过第08篇但rawSql的?是SQL文本的一部分——跳不了。5个?就得绑5个值缺一个PreparedStatement直接报错。所以没传的参数用占位。这个兜底的副作用PAY_DATE ?绑了——Oracle的to_date(“”)会报错。rawSql的参数在调用方必须传全兜底只是防参数个数崩溃不防语义错误。五、和元数据通道的分岔点回到EaEngine.query()第06篇if(sqlMeta.isHasRawSql()){builtSqlsqlBuilder.buildFromRawSql(sqlMeta.getRawSql(),params,...);}else{builtSqlsqlBuilder.buildSelect(sqlMeta,fields,conditions,params,...);}returnexecuteQuery(builtSql,fields);两条通道的分岔只在SQL生成这一步——生成完都汇入同一个executeQuery绑定/执行/postProcess加密脱敏。也就是说能力元数据通道rawSql通道字段级加密解密✓fields元数据驱动postProcess✓EA03照配postProcess按fields走数据脱敏✓✓慢SQL监控✓✓必填/正则校验✓validateParams✗无字段概念动态条件跳过✓✗参数必须传全分页✓✓paginateCustom逃生舱不是法外之地——出了rawSql通道照样享受加密、脱敏、监控只是失去了元数据的校验和动态性。六、安全边界rawSql是最危险的地方CODE_REVIEW_REPORT里最危险攻击链的终点就在rawSqlEaAdminController曾无鉴权 → 攻击者可写任意SQL进EA01.EAE800 → SqlBuilder的固定值拼接放行 → EaEngine执行rawSql → 全库泄露修复后的三道闸EaAdminController加了checkAuth鉴权——元数据管理接口只有管理员能碰#{}强制参数化——rawSql里的值只能通过占位符进#{}替换成?后走PreparedStatement绑定不存在拼接注入runSql类接口白名单——不是任意rawSql都能被执行端点调#{}设计本身就是安全机制——如果当初选择字符串替换replace(#{name}, val)rawSql就是天然SQL注入洞。占位符替换把拼SQL变成绑参数从根上堵死。✅ 亮点46行buildFromRawSql拆成四步讲清LinkedHashSet去重保序和JDBC位置绑定的对应关系、null参数的兜底与rawSql必须传全的约定、#{}占位符替换本身就是防注入的安全机制。适合设计SQL模板功能的人。扩展方向第12篇DdlExecutor自动建表、第34篇96个安全评审问题复盘。