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

相关推荐
wangchunyu1148 小时前
JAVA CompletableFuture异步编程实战
java·开发语言·python
paopaokaka_luck9 小时前
流浪动物救助与收养系统(DeepSeek AI对话、 ECharts多维分析、救助流程衔接、领养与回访衔接、 捐赠确认与公示、志愿者协作)
java·前端·javascript·人工智能·mysql·echarts·推荐算法
Thomas.Sir9 小时前
第9课:SpringBoot4.x Web开发基础:WebMvc自动配置、静态资源、请求参数接收
java·spring boot
程序 代码狂人9 小时前
切片表与分区表的区别(数据开发面试版)
java·大数据·开发语言·数据仓库·算法
2501_9445215910 小时前
Deepmerge 跨端三方库仓颉适配全流程
java·python·eclipse·github
天河归来10 小时前
从原始消息到业务数据:三层数据架构的设计与实践
数据库·数据仓库·架构
天远Date Lab11 小时前
零信任架构实战:基于天远企业年报信息核验构建自动化商业保理财务与社保穿透评估网关
人工智能·架构·自动化
EnCi Zheng11 小时前
后端怎么把AI接口稳定安全低成本接进业务:超时重试、密钥隔离与成本熔断清单
人工智能·后端·安全·架构·api
救救孩子把11 小时前
LangGraph 工程化架构:类型、状态、流式通信与可观测性
架构·agent·trace·opentelemetry·langgraph·graphrag
南归北隐12 小时前
Spring AI Alibaba Graph框架实现MCP数据库查询实战
java·后端·spring ai·mcp·spring ai mcp