07-SqlBuilder六法

07-SqlBuilder六法:SQL是怎么拼出来的

SqlBuilder是元数据引擎的SQL工厂------580行,九个public方法、六个private辅助。所有SQL拼接的规则都集中在这一个类里。这篇把九个build方法逐个拆开,讲清每个方法的输入输出和它们共享的private辅助。

文章目录

源码:browise-metadata/src/main/java/com/browise/ea/core/engine/SqlBuilder.java(580行)


一、全方法地图

复制代码
public入口(9个)
├── buildFromRawSql      原始SQL #{param}替换
├── buildSelect          元数据SELECT(含分页)
├── buildCount           元数据COUNT
├── buildCountCustom     原始SQL包COUNT
├── buildUpdate          元数据UPDATE
├── buildInsert          元数据INSERT
├── buildInsertBySearch  INSERT跳空值版
├── buildDelete          元数据DELETE
├── buildUpdateByFields / buildDeleteByFields  按字段值定位的改删

private辅助(7个)
├── appendColumns        SELECT列表达式(类型→方言分支)
├── appendFrom           FROM子句(多表/主表/DUAL)
├── appendJoinAndWhere   查询条件(固定+动态,全运算符)
├── appendWhereConditions 改删条件(简化版,只=)
├── appendWhereByFields  按字段值拼WHERE
├── typeConvertParam / typeConvertWhereParam  占位符类型转换
├── validateParams       必填+正则校验
└── extractParamNames    #{}参数名提取

输出:BuiltSql { sql, params }  ------SQL文本+按序参数值

二、buildSelect:查询的主装配线

java 复制代码
public BuiltSql buildSelect(sqlMeta, fields, conditions, params, paginate, orderByColumn, minRow, maxRow) {
    validateParams(fields, params);          // ①必填+正则前置校验
    StringBuilder sql = new StringBuilder();
    sql.append("select \n");
    appendColumns(sql, fields);              // ②列表达式(类型分支)
    sql.append(" from \n");
    appendFrom(sql, sqlMeta);                // ③多表/主表/DUAL
    sql.append(" where 1=1 \n");             // ④永真起点------后面全是 AND xxx
    List<String> paramValues = appendJoinAndWhere(sql, conditions, params);  // ⑤条件

    if (paginate && minRow != null && maxRow != null) {
        strSql = dialect.paginate(strSql, orderByColumn);   // ⑥方言分页包装
        if (dialect.supportsOffset()) {      // ⑦两种分页参数序
            paramValues.add(maxRow - minRow + 1);   // LIMIT size
            paramValues.add(minRow - 1);            // OFFSET start
        } else {
            paramValues.add(minRow);                // ROWNUM >= min
            paramValues.add(maxRow);                // ROWNUM <= max
        }
    }
    return new BuiltSql(strSql, paramValues);
}

where 1=1 是老把戏------后面每个条件都以 AND 开头拼,不用判断"第一个条件前要不要AND"。

⑦的两种参数序是分页bug的高发地:LIMIT方言是 size,offset 顺序,ROWNUM方言是 min,max 顺序------同一个BuiltSql.params列表,尾部追加的两个值位置不同。KingBasePg方言就修过一次参数顺序bug(BROWISE-STATUS记录)。


三、appendColumns:类型驱动的方言分支

java 复制代码
private void appendColumns(StringBuilder sql, List<EaFieldMeta> fields) {
    if (fields == null || fields.isEmpty()) {
        sql.append("\t*");                  // 无字段配置 → SELECT *
        return;
    }
    for (EaFieldMeta f : fields) {
        String expr = f.getTableAlias() + "." + f.getColumnName();
        String type = f.getDataType();
        if ("2".equals(type))       // 日期
            sql.append(dialect.selectDateColumn(expr, alias));      // to_char(x,'yyyymmdd')
        else if ("3".equals(type))  // 数字
            sql.append(dialect.selectNumberColumn(expr, alias));    // to_char防精度丢失
        else if ("4".equals(type))  // 日期时间
            sql.append(dialect.selectDateTimeColumn(expr, alias));  // to_char完整格式
        else
            sql.append(dialect.selectDefaultColumn(expr, alias));   // 原样
    }
}

SqlBuilder自己不知道Oracle的to_char怎么写------它只知道"日期类型要问方言"。所有数据库差异封装在Dialect的八个方法里(第09篇)。这是典型的策略模式:SqlBuilder是上下文,Dialect是可替换策略。

数字类型也走to_char是政务系统的现实------Oracle NUMBER经JDBC读出来可能变科学计数法(尤其大额金额),先在SQL层转字符串杜绝精度问题。


四、appendFrom:三级降级

java 复制代码
private void appendFrom(StringBuilder sql, EaSqlMeta sqlMeta) {
    List<EaTableMeta> tables = sqlMeta.getTables();
    if (tables != null && !tables.isEmpty()) {
        // ①EA02配了多表 → 逗号分隔的隐式JOIN
        for (...) sql.append(t.getTableName() + " " + t.getTableAlias());
    } else {
        String mainTable = sqlMeta.getMainTable();
        if (mainTable == null || mainTable.isEmpty()) {
            sql.append("\tDUAL");           // ③啥都没有 → DUAL(取序列/系统时间用)
        } else {
            sql.append("\t").append(mainTable);  // ②只有主表
        }
    }
}

DUAL降级 是个贴心设计------一条只取序列或sysdate的SQL(SELECT seq.nextval FROM DUAL)不需要配任何表。


五、buildInsert的两个版本

java 复制代码
// 标准版:全字段出现,空值插NULL
public BuiltSql buildInsert(sqlMeta, fields, params) {
    for (EaFieldMeta f : fields) {
        cols.append(colName);
        if (有值) { vals.append(typeConvertParam(type)); paramValues.add(val); }
        else      { vals.append("NULL"); }        // 占位符都不给,直接NULL字面量
    }
    sql = "insert into T (c1,c2) values (?,NULL)";
}

// 搜索版:跳过空值字段,全空抛异常
public BuiltSql buildInsertBySearch(sqlMeta, fields, params) {
    for (EaFieldMeta f : fields) {
        if (空值) continue;                      // 空字段不出现在列清单里
    }
    if (paramValues.isEmpty())
        throw new EaException(PARAM_REQUIRED, "无有效插入数据");
    sql = "insert into T (c1,c2) values (?,?)";  // 只有非空列
}

区别在空字段的语义:标准版空值=NULL写入(覆盖式保存);搜索版空值=不插入(保持数据库默认值/触发器赋值)。表单"没填的字段保持默认"用搜索版,"没填就是清空"用标准版。


六、buildUpdate的NULL语义

java 复制代码
public BuiltSql buildUpdate(sqlMeta, fields, conditions, whereParams, valueParams) {
    sql.append("update ").append(resolveTableName(sqlMeta)).append("\n set \n");
    for (EaFieldMeta f : fields) {
        String val = valueParams.get(f.getColumnName());
        if (非空) { sql.append(colName + "=" + typeConvertParam(type)); paramValues.add(val); }
        else      { sql.append(colName + "=NULL"); }    // 前端传空 → 数据库置NULL
    }
    sql.append(" where 1=1 \n");
    paramValues.addAll(appendWhereConditions(sql, conditions, whereParams));
}

UPDATE的空值=NULL 和INSERT标准版语义一致------这在配合 _t/_o 协议时有个讲究:前端 collect 收集的 UPDATE 行只带修改过的字段 (没改的不在payload里)。所以 valueParams 里没有的字段不会出现在SET里吗?不对------buildUpdate遍历的是全部fields,没传的字段全部SET NULL。

这正是政务编辑表单的约定:表单一次提交全量字段,清空的字段就是要把库里清空。要"只更新传了的字段",用 buildUpdateByFields(按originalParams里的字段值定位行)。


七、appendJoinAndWhere vs appendWhereConditions:一个逻辑两副面孔

查询用的 appendJoinAndWhere(第08篇细讲条件规则)支持全部运算符;改删用的 appendWhereConditions 是简化版:

java 复制代码
// 简化版:固定值也参数化,动态参数只支持=
if (c.getConditionType() == 1) {
    sql.append(c.getColumnName() + "=?");
    paramValues.add(c.getConditionValue());   // 固定值也走占位符!
} else {
    sql.append(c.getColumnName() + "=" + typeConvertWhereParam(type, "="));  // 运算符写死=
}

为什么改删的固定值也参数化? 这是安全修复(CODE_REVIEW_REPORT 2.11)的一部分------查询版固定值直接拼SQL(AND u.STATUS='1'),因为值来自管理员配置的元数据表;改删影响数据,固定值也走占位符多一层保险。


八、validateParams:拼SQL前的最后一道闸

java 复制代码
private void validateParams(List<EaFieldMeta> fields, Map<String, String> params) {
    for (EaFieldMeta f : fields) {
        if (!f.isRequired()) continue;
        String key = f.getFieldAlias() != null ? f.getFieldAlias() : f.getColumnName();
        String val = params.get(key);
        if (val == null) val = params.get(f.getColumnName());   // 别名查不到回退列名
        if (val == null || val.trim().isEmpty())
            throw new EaException(PARAM_REQUIRED, f.getTitle());
        if (f.getRegexRule() != null && !Pattern.matches(f.getRegexRule(), val))
            throw new EaException(PARAM_FORMAT_ERROR, f.getTitle());
    }
}

必填校验和正则校验在buildSelect入口处 ------不是在Controller层做。理由:元数据引擎的API(/ea/query/{sqlId})是通用入口,没有对应的Controller方法可以写校验------校验跟着元数据走,谁调用都逃不过


九、BuiltSql:不可变的结果对象

java 复制代码
public static class BuiltSql {
    private final String sql;
    private final List<String> params;
    // 只有getter,没有setter
}

拼好的SQL和参数绑在一起传递------SQL里的?个数必须和params.size()严格相等,分开传容易出现错位。这个不变式由build方法的构造逻辑保证,BuiltSql一旦生成就不可改。


✅ 亮点:580行SqlBuilder拆成9入口+7辅助的完整地图,重点讲类型驱动的方言分支、INSERT两版本的空值语义差异、改删固定值也参数化的安全修复、必填正则校验为什么放在引擎层。适合写SQL生成器的参考。扩展方向:第08篇展开条件拼接规则、第09-10篇拆方言体系。

相关推荐
LONGZETECH20 分钟前
新能源汽车动力电池实训教学痛点与虚拟仿真技术解决方案
c语言·3d·unity·架构·汽车·汽车教学软件
BS30813_vx20 分钟前
【编号:01652】Spring Boot村务管理系统:财务公开、党建活动与计生服务一体化实战
开发语言·python
孙69034222 分钟前
Spring 注入多例 Bean
java·spring
Jul1en_23 分钟前
【Java 脚手架】封装通用工具类-3
java·开发语言·redis·缓存·ai·bootstrap·rabbitmq
学习星球30 分钟前
2026年AI Agent全栈开发实战——从Prompt到Production
开发语言·人工智能·prompt
Fluxart.ai1 小时前
商品多角度图怎么做?Flux Art 从白底图到规格图、包装图的 10 步教程
开发语言·前端·javascript
边境悍匪1 小时前
springboot常用注解
java·spring boot·学习
叶落方知秋1 小时前
大模型学习笔记:特征工程到底在干啥?
架构