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个安全评审问题复盘。

相关推荐
panxianren7 小时前
Bun 1.4 的 bun test --parallel 将 4 秒的测试套件缩短到约 1 秒
java·javascript
sunshine22 girl9 小时前
Java学习七 Java 项目实战2-注册业务
java·学习
省钱兄--zs9 小时前
北京24小时自助健身房系统软件开发实战:从架构设计到部署指南
java·数据仓库·spring boot·小程序·需求分析
youdexiang10 小时前
Android录音软件时间关键词检索功能分析
java·人工智能
sunshine22 girl10 小时前
Java学习七 Java 项目实战1-项目创建和主页面搭建
java·开发语言·学习
code2cat10 小时前
Java进阶篇之AtomicStampedReference:引用回到原值,版本戳仍能记住变化
java·cas·并发编程·aba
泡茶喝茶写代码12 小时前
A股量化数据工程:从 REST 接口到策略信号(第 8 篇):成长能力因子:营收与利润增速
java·python·股票数据api·股票数据api接口·股票量化数据api·股票量化数据接口·股票数据api数据
搭贝12 小时前
上厕所的时间搭好一套系统:AI自动生成实测
开发语言·人工智能·低代码·ai·php·搭贝
\光辉岁月/13 小时前
7.javase-面向对象
java·开发语言
weixin_5310946913 小时前
heap_4内存管理
java·开发语言·算法