11-rawSql模式:#{}占位符替换与参数收集
五表元数据覆盖不了所有SQL------复杂统计、多层子查询、UNION。EA01的rawSql字段是逃生舱口:整条SQL存进元数据,引擎只做三件事------#{param}换成?、按出现顺序收集参数、方言分页包装。这篇拆buildFromRawSql的46行和extractParamNames的正则。
文章目录
源码:
browise-metadata/src/main/java/com/browise/ea/core/engine/SqlBuilder.javaL46-76 + L514-524
一、rawSql存在的意义
五表模型的边界(第08篇末尾说过):不支持子查询、顶层OR、函数条件。真实系统里这些需求不少:
sql
-- 按月统计各险种征缴额,含环比
SELECT t.MONTH, t.AMT,
(t.AMT - LAG(t.AMT) OVER (ORDER BY t.MONTH)) / LAG(t.AMT) OVER (ORDER BY t.MONTH) AS GROWTH
FROM (
SELECT TO_CHAR(PAY_DATE,'yyyymm') AS MONTH, SUM(PAY_AMT) AS AMT
FROM IC50
WHERE PAY_DATE >= #{startDate} AND PAY_DATE < #{endDate}
GROUP BY TO_CHAR(PAY_DATE,'yyyymm')
) t
分析函数、子查询、聚合------五表描述不了。存EA01.EAE800,标记EAE801=1,走rawSql通道。
二、buildFromRawSql的46行
java
public BuiltSql buildFromRawSql(String rawSql, Map<String, String> params, boolean paginate,
String orderByColumn, Integer minRow, Integer maxRow) {
if (rawSql == null || rawSql.trim().isEmpty()) {
throw new EaException(EaExceptionType.SQL_ERROR, "EA01无SQL内容");
}
// ①提取所有 #{paramName}(保持出现顺序,去重)
List<String> paramNames = extractParamNames(rawSql);
List<String> paramValues = new ArrayList<>();
// ②全部 #{xxx} 一刀切换成 JDBC的 ?
String preparedSql = rawSql.replaceAll("#\\{[^}]+}", "?");
// ③按出现顺序收集参数值
for (String name : paramNames) {
String val = params.get(name);
paramValues.add(val != null ? val : "");
}
// ④可选分页包装
if (paginate && minRow != null && maxRow != null) {
preparedSql = dialect.paginateCustom(preparedSql, orderByColumn);
if (dialect.supportsOffset()) {
paramValues.add(String.valueOf(maxRow - minRow + 1));
paramValues.add(String.valueOf(minRow - 1));
} else {
paramValues.add(String.valueOf(minRow));
paramValues.add(String.valueOf(maxRow));
}
}
return new BuiltSql(preparedSql, paramValues);
}
四步:提取参数名→全局替换→按序取值→分页包装。
三、extractParamNames:正则+LinkedHashSet去重保序
java
private List<String> extractParamNames(String sql) {
List<String> names = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>(); // 保插入序的去重
Pattern p = Pattern.compile("#\\{([^}]+)}"); // #{后跟非}字符直到}
Matcher m = p.matcher(sql);
while (m.find()) {
String name = m.group(1);
if (seen.add(name)) names.add(name); // 首次出现才进names
}
return names;
}
两个关键点:
为什么去重------同一参数出现多次:
sql
WHERE PAY_DATE >= #{monthStart} OR BACKUP_DATE >= #{monthStart}
#{monthStart}出现两次→替换成两个?→两个问号都要绑定值。但参数名列表只留一个(names去重了)------那第二个问号的值从哪来?
看③的收集逻辑:paramValues只按names(去重后)加了一次!这是个刻意的行为还是bug?
实测会bind失败(参数个数不匹配)。所以rawSql里同一参数出现多次时,必须写成两个不同名的参数:
sql
WHERE PAY_DATE >= #{monthStart} OR BACKUP_DATE >= #{monthStart2}
-- 前端传 {monthStart:"2026-01-01", monthStart2:"2026-01-01"}
这是rawSql通道的使用约定------管理界面的SQL编辑器有提示。设计上可以改成"按?的出现次数重复收集",但那要求正则替换和收集同步进行(MyBatis的做法),当前实现选择了简单。
为什么保序 ------replaceAll把所有#{}按文本位置顺序 换成?。JDBC的?按位置绑定。参数值列表的顺序必须和?的出现顺序一致------names保序就是为这个。
四、null参数的""兜底
java
String val = params.get(name);
paramValues.add(val != null ? val : ""); // 没传→空字符串
动态条件可以跳过(第08篇),但rawSql的?是SQL文本的一部分------跳不了 。5个?就得绑5个值,缺一个PreparedStatement直接报错。所以没传的参数用""占位。
这个兜底的副作用:PAY_DATE >= ?绑了""------Oracle的to_date("")会报错。rawSql的参数在调用方必须传全,""兜底只是防参数个数崩溃,不防语义错误。
五、和元数据通道的分岔点
回到EaEngine.query()(第06篇):
java
if (sqlMeta.isHasRawSql()) {
builtSql = sqlBuilder.buildFromRawSql(sqlMeta.getRawSql(), params, ...);
} else {
builtSql = sqlBuilder.buildSelect(sqlMeta, fields, conditions, params, ...);
}
return executeQuery(builtSql, fields);
两条通道的分岔只在SQL生成这一步------生成完都汇入同一个executeQuery(绑定/执行/postProcess加密脱敏)。也就是说:
| 能力 | 元数据通道 | rawSql通道 |
|---|---|---|
| 字段级加密解密 | ✓(fields元数据驱动postProcess) | ✓(EA03照配,postProcess按fields走) |
| 数据脱敏 | ✓ | ✓ |
| 慢SQL监控 | ✓ | ✓ |
| 必填/正则校验 | ✓(validateParams) | ✗(无字段概念) |
| 动态条件跳过 | ✓ | ✗(参数必须传全) |
| 分页 | ✓ | ✓(paginateCustom) |
逃生舱不是法外之地------出了rawSql通道照样享受加密、脱敏、监控,只是失去了元数据的校验和动态性。
六、安全边界:rawSql是最危险的地方
CODE_REVIEW_REPORT里"最危险攻击链"的终点就在rawSql:
EaAdminController(曾无鉴权)
→ 攻击者可写任意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个安全评审问题复盘。