11-rawSql占位符替换

11-rawSql模式:#{}占位符替换与参数收集

五表元数据覆盖不了所有SQL------复杂统计、多层子查询、UNION。EA01的rawSql字段是逃生舱口:整条SQL存进元数据,引擎只做三件事------#{param}换成?、按出现顺序收集参数、方言分页包装。这篇拆buildFromRawSql的46行和extractParamNames的正则。

文章目录

源码:browise-metadata/src/main/java/com/browise/ea/core/engine/SqlBuilder.java L46-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
  → 全库泄露

修复后的三道闸:

  1. EaAdminController加了checkAuth鉴权------元数据管理接口只有管理员能碰
  2. #{}强制参数化 ------rawSql里的值只能通过占位符进,#{}替换成?后走PreparedStatement绑定,不存在拼接注入
  3. runSql类接口白名单------不是任意rawSql都能被执行端点调

#{}设计本身就是安全机制 ------如果当初选择字符串替换(replace("#{name}", val)),rawSql就是天然SQL注入洞。占位符替换把"拼SQL"变成"绑参数",从根上堵死。


✅ 亮点:46行buildFromRawSql拆成四步,讲清LinkedHashSet去重保序和JDBC位置绑定的对应关系、null参数的""兜底与rawSql必须传全的约定、#{}占位符替换本身就是防注入的安全机制。适合设计SQL模板功能的人。扩展方向:第12篇DdlExecutor自动建表、第34篇96个安全评审问题复盘。

相关推荐
我叫黑大帅1 小时前
关于没有对生产者做校验的思考
前端·面试·架构
葡萄城技术团队1 小时前
从MaxKB说起:活字格 + ikkyu-kb 探索企业知识库的另一种路径
低代码
码匠许师傅1 小时前
【C++ 面试真题】聊聊 C++ 的类型特征( type_traits)
java·c++·面试
java1234_小锋1 小时前
Spring框架的创始人开发了一个Java AI智能体框架
java·人工智能·spring
夏天拐跑了西瓜1 小时前
Eureka注册中心——单机与高可用集群搭建
java·spring cloud·微服务·eureka
Caster_Z1 小时前
IntelliJ IDEA 安装 Qoder CN (Formerly Lingma)
java·ide·intellij-idea
mmsx1 小时前
Android 宠物健康服务平台实战:从架构设计到主题换肤完整解析(源码和文档)
android·架构·sqlite·app·源码·宠物
前端炒粉1 小时前
fetch+readablestream(Streamable)流式输出
java·服务器·前端
珍珠先生1 小时前
第18章 泛型入门
java