AIGC标识 Army 参数绑定与字面量绑定深度解析

Army 参数绑定与字面量绑定深度解析(源码级)

Army GitHub:https://github.com/PillArmy/army

本文基于 Army 当前源码(army-core / army-jdbc / army-mysql / army-postgre,master 分支)撰写,所有结论均给出源码文件与行号,可逐条核对。对比部分参考上游源码:MyBatis 3 与 Hibernate ORM 6。


目录


0. 一句话总览

Army 把"值进入 SQL"的方式严格分成两条互斥的管道:

管道 API SQL 形态 值何时确定 谁负责最终落地
参数绑定 SQLs.param() / parameter() / namedParam() JDBC 占位符 ? SQL 渲染期只登记,执行期由 PreparedStatement#setXxx 绑定 方言 Executor(MySQLExecutor / PostgreExecutor)
字面量绑定 SQLs.literal() / constValue() / namedLiteral() 值直接渲染进 SQL 文本 SQL 渲染期由方言 LiteralHandler 安全转义后内联 方言 LiteralHandler(MySQLLiteralHandler / PostgreLiteralHandler)

两条管道都经过 MappingType 的类型校验与转换;参数走 JDBC 协议天然免疫注入,字面量走"类型校验 + 强制转义"同样免疫注入。Army 不存在 MyBatis ${} 那种无保护的裸拼接通道。


1. 参数绑定(Parameter Binding)

1.1 什么是参数绑定

参数绑定即标准 JDBC 的 PreparedStatement 占位符机制:SQL 文本里只有 ?,真实值通过协议在 SQL 之外发送给数据库:

-- SQL 文本(Army 渲染产物)
SELECT id, name FROM users WHERE age > ? AND status = ?
-- 参数列表(不进入 SQL 文本)
[18, "ACTIVE"]

1.2 三个核心 API

API 类型来源 值 典型场景
SQLs.parameter(Object value) 按值的 Java 类型自动推断 当场给定 单条语句、简单值
SQLs.param(TypeInfer type, Object value) 显式指定(MappingType / FieldMeta / TypedField) 当场给定,可为 null CTE 引用字段、null 值、精确控类型
SQLs.namedParam(TypeInfer type, String name) 显式指定 按名字延迟取值 批量 UPDATE/DELETE、VALUES 批量插入

另有 namedNullableParam(允许 null 的命名参数)、encodingParam / encodingNamedParam(专供 codec 加密字段)等变体。

1.3 参数表达式的内部结构

SQLs.parameter(value) 的实现(ArmyParamExpression.java L43-L53):

/// @see SQLs#parameter(Object)
static ParamExpression from(final @Nullable Object value) {
    if (value == null) {
        throw ContextStack.clearStackAndNullPointer();
    }
    final MappingType type;
    type = _MappingFactory.getDefaultIfMatch(value.getClass());  // 按 Class 查默认 MappingType
    if (type == null) {
        throw CriteriaUtils.clearStackAndNonDefaultType(value);   // 不认识的类型直接拒绝
    }
    return single(type, value);
}

显式类型版本(同文件 L62-70):

static ArmyParamExpression single(final @Nullable TypeInfer infer, final @Nullable Object value) {
    final TypeMeta type;
    if (infer == null) {
        throw ContextStack.clearStackAndNullPointer();
    } else if ((type = infer.typeMeta()) instanceof TableField && ((TableField) type).codec()) {
        throw ArmyParamExpression.typeInferReturnCodecField("encodingParam"); // 加密字段必须走 encodingParam
    }
    return new AnonymousParam(type, value);
}

参数对象本身 AnonymousParam(同文件 L181-236)只持有两样东西:TypeMeta type 与 Object value,它的 toString() 固定是 " ?"——它从不把 value 拼进 SQL。


2. 字面量绑定(Literal Binding)

2.1 什么是字面量绑定

字面量绑定指把值在 SQL 渲染阶段直接内联为 SQL 文本的一部分,例如:

-- MySQL
SELECT id, name FROM users WHERE status = 'ACTIVE' AND age > 18
-- PostgreSQL(带类型前缀)
SELECT id, name FROM users WHERE status = 'ACTIVE'::VARCHAR AND age > 18

2.2 核心 API

API 类型来源 是否输出类型前缀
SQLs.literalValue(Object value) 自动推断 是(typeName=true)
SQLs.literal(TypeInfer type, Object value) 显式指定 是
SQLs.constValue(Object value) 自动推断 否(typeName=false)
SQLs.constant(TypeInfer type, Object value) 显式指定 否
SQLs.namedLiteral(TypeInfer type, String name) 显式指定,按名延迟取值 是,仅批量 VALUES INSERT
SQLs.namedConst(TypeInfer type, String name) 同上 否

字面量表达式的构造(ArmyLiteralExpression.java L44-L54)与参数几乎对称,唯一差别是最终 appendSql 的去向:

// AnonymousLiteral(同文件 L200-208)
@Override
public void appendSqlWithoutType(StringBuilder sqlBuilder, _SqlContext context) {
    context.appendLiteral(this.type, this.value, false);
}

@Override
public final void appendSql(final StringBuilder sqlBuilder, final _SqlContext context) {
    context.appendLiteral(this.type, this.value, this.typeName);  // 走字面量管道
}

对比参数表达式(ArmyParamExpression.java L175-L178):

@Override
public final void appendSql(StringBuilder sqlBuilder, _SqlContext context) {
    context.appendParam(this);   // 走参数管道
}

两条管道在表达式层就分道扬镳,这是整个绑定体系最重要的分界点。


3. 参数的隐式绑定与显式绑定

3.1 显式绑定

开发者亲手写出值表达式,称为显式绑定:

// 显式参数
.where(User_.age.greater(SQLs.parameter(18)))
.where(User_.name.equal(SQLs.param(StringType.INSTANCE, "Alice")))
.where(User_.id.in(SQLs.rowParam(IntegerType.INSTANCE, List.of(1, 2, 3))))

// 显式字面量
.where(User_.status.equal(SQLs.literal(User_.status, Status.NORMAL)))
.set(User_.version, SQLs.LITERAL_1)   // 预定义字面量常量

3.2 隐式绑定(最常用、最简洁)

Army 允许把裸 Java 值直接传给表达式方法,框架自动包装:

// 裸值 —— 隐式绑定
.where(User_.age.greater(18))
.and(User_.name.equal("Alice"))
.and(User_.status.equal(Status.NORMAL))
.set(User_.name, "Bob")

调用链为:

field.equal(rawValue)                               // OperationExpression L55
  → Expressions.biPredicate(left, EQUAL, right)     // Expressions L326
    → Expressions.wrapRight(left, right)            // Expressions L80
      → Expressions.wrapValue((TypedField) left, right)  // 从左操作数字段拿 TypeInfer

核心实现(Expressions.java L80-L94):

static Expression wrapRight(final Expression left, final @Nullable Object right) {
    final Expression rightExp;
    if (right == SQLs.ABSENT) {
        throw ContextStack.clearStackAndNullPointer("right operand couldn't be absent.");
    } else if (right instanceof Expression) {
        rightExp = (Expression) right;              // 已经是表达式,原样使用
    } else if (left instanceof TypedField) {
        rightExp = wrapValue((TypedField) left, right);  // ★ 从左字段推断类型
    } else if (right == null) {
        throw ContextStack.clearStackAndNullPointer("right operand must non-null");
    } else {
        rightExp = wrapNonNull(right);              // 无字段上下文时按值自身类型
    }
    return rightExp;
}

wrapValue 根据全局 LiteralMode 决定裸值被包装成参数还是字面量(同文件 L148-181):

static ArmyExpression wrapValue(final TypeInfer infer, final @Nullable Object value) {
    final Expression valueExp;
    switch (LITERAL_MODE) {
        case DEFAULT:                              // 默认:全部走参数 ?
            valueExp = SQLs.param(infer, value);
            break;
        case LITERAL:                              // 全部内联为带类型前缀字面量
            valueExp = SQLs.literal(infer, value);
            break;
        case CONST:                                // 全部内联为不带前缀常量
            valueExp = SQLs.constant(infer, value);
            break;
        case PREFERENCE: {                         // 智能:数字等无注入风险类型内联,其余走参数
            final MappingType type = ...;
            if (type instanceof _ArmyNoInjectionType) {
                valueExp = SQLs.literal(infer, value);
            } else {
                valueExp = SQLs.param(infer, value);
            }
        }
        break;
        ...
    }
    return (ArmyExpression) valueExp;
}

LiteralMode 枚举(LiteralMode.java L21-L31)共四档,通过配置项 expression.literal.mode 加载(Expressions 静态块 L53-64):

模式 裸值行为 适用场景
DEFAULT 一律 ? 参数 默认,最安全、可复用预编译计划
PREFERENCE 数字/布尔等 _ArmyNoInjectionType 内联,字符串等走 ? 兼顾计划缓存与 SQL 简洁
LITERAL 全部内联(带类型前缀) 框架自管字段(如批量插入行号)
CONST 全部内联(不带前缀) 生成纯常量 SQL

3.3 隐式绑定的类型从哪来

关键在于 wrapRight 优先用左操作数字段的 TypeInfer,而不是值自身的类型。因此:

User_.age.greater((short) 18)
// 左字段 age 是 IntegerType → 18 按 IntegerType 绑定,Short 经 beforeBind 转为 int
// 而不是按 ShortType 绑定

没有字段上下文时(如 SQLs.literalValue(18)),退化为按值的 Class 查 _MappingFactory.getDefaultIfMatch(_MappingFactory.java L69-L94):Integer→IntegerType、String→StringType、枚举→CodeEnumType/LabelEnumType/NameEnumType、@DefinedType POJO→CompositeType 等。


4. 参数绑定的负责组件(完整链路)

参数绑定横跨 SQL 渲染期 与 JDBC 执行期 两个阶段,由四个组件接力:

SQLs.param(...)                              [表达式层]
   └─ ArmyParamExpression.AnonymousParam     持有 (TypeMeta, value)
        │ appendSql()
        ▼
StatementContext.appendParam(sqlParam)       [渲染层:写 '?',登记参数]
        │  产出:sqlText="... ? ..."  paramGroup=[SQLParam,...]
        ▼
SimpleStmt.paramGroup()  →  JdbcExecutor     [执行层]
        │
        ├─ type.map(serverMeta)              MappingType → 方言 DataType
        ├─ type.beforeBind(dataType,env,v)   Java 值转换/校验
        ▼
MySQLExecutor / PostgreExecutor.bind(...)    [方言层:PreparedStatement#setXxx]

4.1 渲染层:写 ? 并登记参数

StatementContext.java L188-L226:

@Override
public final void appendParam(final SQLParam sqlParam) {
    final ArrayList<SQLParam> paramList = this.paramAccepter.paramList;
    if (sqlParam instanceof SingleParam) {
        this.sqlBuilder.append(SPACE_PLACEHOLDER);   // 写一个 '?'
        paramList.add(sqlParam);                     // 参数登记到 paramGroup
    } else if (sqlParam instanceof MultiParam) {
        appendMultiParamPlaceholder(...);            // IN 列表:写 '(?, ?, ?)'
        paramList.add(sqlParam);
    } else if (sqlParam instanceof NamedParam.NamedSingle) {
        this.sqlBuilder.append(SPACE_PLACEHOLDER);
        if (this.paramAccepter.nameValueFunc == null) {
            paramList.add(sqlParam);                 // 批量:延迟,保留名字
            ...
        } else {
            paramList.add(SingleParam.build(sqlParam.typeMeta(),
                    readNamedValue((NamedParam) sqlParam)));  // 当场按名取值
        }
    }
    ...
}

注意:SQL 文本与参数值从此物理分离——StringBuilder 里只有 ?,值在 paramList 里。

4.2 执行层:遍历参数、转换类型、绑定到 PreparedStatement

JdbcExecutor.bindParameters() L1323-L1399(节选):

private void bindParameters(final PreparedStatement statement, final List<SQLParam> paramGroup)
        throws SQLException {
    ...
    for (int i = 0; paramIndex = 1, ...; i < paramSize; i++) {
        sqlParam = paramGroup.get(i);
        typeMeta = sqlParam.typeMeta();
        type = (typeMeta instanceof MappingType) ? (MappingType) typeMeta : typeMeta.mappingType();
        dataType = type.map(serverMeta);                       // ① 映射成方言 DataType
        ...
        value = ...;                                           // ② 取 SingleParam / MultiParam 的值
        if (value == null) {
            statement.setNull(paramIndex++, Types.NULL);       // ③ null
            continue;
        }
        value = type.beforeBind(dataType, mappingEnv, value);  // ④ Java→Java 转换 + 校验
        ...
        bind(statement, paramIndex++, type, dataType, value);  // ⑤ 方言落地:setXxx
    }
}

4.3 方言层:真正的 setXxx

bind 是抽象方法(JdbcExecutor.java L399-L401),各方言实现:

  • MySQL(MySQLExecutor.bind() L156-L211):按 MySQLType switch,强类型 setInt / setLong / setShort,默认分支 bindArmyType 处理字符串、时间等:
case INT: {
    if (!(value instanceof Integer)) {
        throw beforeBindMethodError(type, dataType, value);  // 类型不符直接抛异常
    }
    stmt.setInt(indexBasedOne, (Integer) value);
}
  • PostgreSQL(PostgreExecutor.bind() L186-L214):数组、复合类型、范围等高级类型统一构造 PGobject 走 setObject(PG 文本协议),标量走各自 setter:
if (dataType.isArray()) {
    stmt.setObject(indexBasedOne, createPgObject(type, dataType, value));
}
...
case UUID:
    stmt.setObject(indexBasedOne, value);

Army JDBC 层没有使用 Connection.createArrayOf / createStruct,PG 数组/复合类型全部序列化为 PG 文本表示经 PGobject 发送——这也是 Army 能统一处理 pgvector、range、composite 的原因。


5. 字面量绑定的负责组件(完整链路)

SQLs.literal(...) / constValue(...)         [表达式层]
   └─ ArmyLiteralExpression.AnonymousLiteral  持有 (TypeMeta, value, typeName)
        │ appendSql()
        ▼
StatementContext.appendLiteral(type,value,typeName)   [渲染层]
        │  标记 hasLiteral,调用 parser.safeLiteral
        ▼
ArmyParser.safeLiteral()                     [核心层 L745]
        ▼
ArmyLiteralHandler.safeLiteral()             [核心层 L61:map + beforeBind]
        ▼
MySQLLiteralHandler.bindLiteral()            [方言层:按类型渲染]
  / PostgreLiteralHandler.bindLiteral()
        │
        ├─ 数字/布尔:instanceof 校验后直接 append
        ├─ 字符串:MySQLLiterals.mysqlEscapes / _PostgreLiterals.quoteEscape 转义
        └─ 二进制:0x.. / \x.. 十六进制编码

5.1 渲染入口

StatementContext.java L228-L235:

@Override
public final void appendLiteral(final TypeMeta typeMeta, final @Nullable Object value, boolean typeName) {
    if (!this.paramAccepter.hasLiteral) {
        this.paramAccepter.setHasLiteral();
    }
    this.parser.safeLiteral(typeMeta, value, typeName,
            this.sqlBuilder.append(_Constant.SPACE));
}

5.2 核心处理器:先映射、再转换、最后交给方言

ArmyLiteralHandler.safeLiteral() L61-L83:

public final void safeLiteral(TypeMeta typeMeta, @Nullable Object value, boolean typeName,
                              StringBuilder sqlBuilder, @Nullable DataType container) {
    final MappingType type = (typeMeta instanceof MappingType)
            ? (MappingType) typeMeta : typeMeta.mappingType();
    final DataType dataType = type.map(this.serverMeta);              // ① 方言类型映射

    if (value != null) {
        value = type.beforeBind(dataType, this.mappingEnv, value);    // ② 与参数绑定同一个 beforeBind
    }
    if (value instanceof Temporal && typeMeta instanceof FieldMeta && this.truncatedTimeType) {
        value = _TimeUtils.truncatedIfNeed(((FieldMeta<?>) typeMeta).scale(), (Temporal) value);
    }
    bindLiteral(typeMeta, dataType, value, typeName, sqlBuilder, container);  // ③ 方言渲染(抽象)
}

注意 ②:字面量与参数共用同一个 MappingType.beforeBind 转换逻辑,两条管道在"值转换"上语义完全一致,差别只在最终落地方式(setXxx vs 文本转义内联)。

5.3 方言渲染(以 MySQL 为例)

MySQLLiteralHandler.bindLiteral() L41-L133(节选):

case INT:
case MEDIUMINT: {
    if (!(value instanceof Integer)) {
        throw ExecutorSupport.beforeBindMethodError(typeMeta.mappingType(), dataType, value);
    }
    sqlBuilder.append(value);                     // 数字:校验类型后直接追加,无引号
}
...
case CHAR:
case VARCHAR:
case TEXT:
case JSON: {
    if (!(value instanceof String)) {
        throw ExecutorSupport.beforeBindMethodError(...);
    }
    MySQLLiterals.mysqlEscapes(this.literalEscapeMode, (String) value, sqlBuilder);  // 字符串:强制转义
}
case BLOB: {
    sqlBuilder.append("0x")
              .append(_Literals.hexEscapes((byte[]) value));   // 二进制:十六进制
}

PostgreSQL 侧字符串统一走 PostgreLiteralHandler.stringEscape() L349-L374,按 literalEscapeMode 分派给 _PostgreLiterals.quoteEscape / backslashEscape / unicodeEscape。


6. 两种字面量渲染:literal 与 const 的区别

literal* 系列与 const* 系列的唯一构造差异是 typeName 布尔值(见 ArmyLiteralExpression.java L56-L62),它控制是否输出 SQL 类型前缀:

值 literal(typeName=true) const(typeName=false)
整数 100 PG:100::INTEGER;MySQL:100 100(各方言一致)
BigDecimal("0.00") PG:0.00::DECIMAL 0.00
LocalDate.of(2024,1,1) DATE '2024-01-01' '2024-01-01'
LocalDateTime TIMESTAMP '2024-01-01 12:00:00' '2024-01-01T12:00:00'
字符串 "hi" PG:'hi'::VARCHAR;MySQL:'hi' 'hi'

PostgreSQL 对字符串类类型的实际渲染(PostgreLiteralHandler.java L287-L297):

if (typeName) {
    sqlBuilder.append(dataType.typeName()).append(_Constant.SPACE);
    // 采用 dataType 'string' 语法而非 'string'::dataType,源码注释解释:
    // XMLEXISTS 在该形式下才能正常工作
}
stringEscape((String) value, sqlBuilder, container);

如何选择

  • const(默认推荐给纯静态常量):SQL 更短、跨方言一致,适合状态码、固定数字等。
  • literal(需要类型消歧时):防止数据库隐式类型转换,例如日期字面量加 DATE/TIMESTAMP 前缀、PG 字符串加类型前缀。

两者安全性完全相同(转义路径一致),差别仅在类型前缀。预定义常量也成对存在:SQLs.LITERAL_1 ↔ SQLs.CONST_1、SQLs.LITERAL_EMPTY_STRING ↔ SQLs.CONST_EMPTY_STRING。


7. SQL 注入分析:参数与字面量分别安全吗?

结论先行:两种绑定方式都不会产生 SQL 注入。 但防护机理不同。

7.1 参数绑定:协议级免疫

参数的值从不进入 SQL 文本。渲染期 StatementContext.appendParam 只写 ?;执行期值通过 PreparedStatement.setInt/setLong/setString/setObject 经 JDBC 协议单独传输。数据库把 SQL 文本当"程序"、把参数当"数据"解析,值在语法上永远不可能被解释为 SQL 关键字。这与 MyBatis #{}、Hibernate HQL 位置/命名参数、原生 JDBC 是同一条防线。

7.2 字面量绑定:三层代码级防护

字面量确实进入 SQL 文本,但不是裸拼接,而是经过三层防护:

第一层:按 Java Class 推断类型,字符串无法伪装成数字

ArmyLiteralExpression.from() L44-L54(参数侧的 ArmyParamExpression.from() 同理)都通过 _MappingFactory.getDefaultIfMatch(value.getClass()) 决定 MappingType。"' OR 1=1 --" 的 Class 是 String,只能走字符串渲染分支,不可能进入数字分支。

第二层:数字类型的 instanceof 硬校验 + 无注入类型标记

所有数字/布尔类型继承自 _ArmyNoInjectionType L19-L31:

public abstract class _ArmyNoInjectionType extends _ArmyBuildInCoreType {
    _ArmyNoInjectionType() {
        assertNonString(this);   // 该体系明确禁止 String 类型混入
    }
    private static void assertNonString(_ArmyNoInjectionType type) {
        if (type instanceof SqlString) {
            throw new IllegalStateException("sub class error.");
        }
    }
}

方言渲染数字字面量时再次 instanceof 校验(MySQLLiteralHandler L56-L61):不是 Integer 直接抛异常,绝不追加到 SQL。

第三层:字符串强制转义、二进制十六进制编码

  • PostgreSQL(_PostgreLiterals.quoteEscape() L34-L52):逐字符扫描,单引号一律翻倍(标准 SQL 转义,' → '');还提供反斜杠转义与 Unicode 转义两档。
  • MySQL(MySQLLiterals.mysqlEscapes() L28-L118):单引号翻倍;\、NUL、\n、\r、\t、\032 等在反斜杠转义模式下加 \;关键兜底——当无法确认会话 sql_mode(NO_BACKSLASH_ESCAPES)是否允许反斜杠转义、而字符串又含这些危险字符时,直接放弃引号形式,改写为十六进制:
if (!backSlashEscape && charAfterBachSlash != _Constant.NUL_CHAR) {
    sqlBuilder.setLength(startIndex);
    sqlBuilder.append("_utf8mb4 0x");
    sqlBuilder.append(HexUtils.hexEscapesText(true, literal.getBytes(StandardCharsets.UTF_8)));
}
  • 二进制:MySQL 0x...,PG BYTEA '\x...',不经字符串路径。

7.3 攻击载荷演练

String evil = "'; DROP TABLE users; --";

// ① 参数:SQL 文本永远是 ... = ?,载荷作为 String 数据发送 → 安全
User_.name.equal(SQLs.param(StringType.INSTANCE, evil))

// ② 字面量:单引号翻倍,整体是一个普通字符串常量 → 安全
SQLs.literalValue(evil)
// MySQL:  '\''; DROP TABLE users; --'

// ③ 试图伪装成数字:类型推断为 StringType,且数字分支有 instanceof 校验 → 不可能
// SQLs.literalValue(evil) 永远不会走到 sqlBuilder.append(value) 的 INT 分支

// ④ 二进制:十六进制编码 → 安全
SQLs.literalValue(evil.getBytes(StandardCharsets.UTF_8))
// 0x273b2044524f50...

7.4 Army 为什么"天然没有 ${} 陷阱"

Army 是编译期 Criteria API,标识符(表名、列名)由生成的元数据类(User_、TableMeta)提供,值只能通过 param/literal 两条受管通道进入;框架根本不提供"把一段用户文本原样拼进 SQL"的公开 API。这与 MyBatis 保留 ${} 作为逃生舱有本质区别(详见第 12 节)。

7.5 工程建议

字面量虽然安全,但用户动态输入仍优先用 param:

  1. ? 参数可复用数据库预编译计划,高并发下性能更稳;
  2. 避免同一逻辑 SQL 因内联值不同而污染 SQL 审计日志/慢查询统计;
  3. 字面量适合:框架内部常量、固定状态码、DDL/函数参数中不允许占位符的位置、批量插入行号(BATCH_NO_LITERAL)。

8. MappingType 在绑定中的作用

MappingType 是 Army 类型系统的中枢,定义见 MappingType.java L34-L74。它是一个 sealed 接口,在绑定全链路中承担五个职责:

8.1 声明 Java 类型

Class<?> javaType();

例如 IntegerType.javaType() 返回 Integer.class(IntegerType.java L60-L63)。

8.2 方言类型映射(Java 类型 → SQL DataType)

DataType map(ServerMeta meta) throws UnsupportedDialectException;

同一个 IntegerType 在三种方言下分别映射为 MySQLType.INT / PgType.INTEGER / SQLiteType.INTEGER(同文件 L149-166)。参数绑定与字面量绑定都先调 map,后续一切分派都以方言 DataType 为准。这让上层 Criteria API 完全与方言解耦。

8.3 写入前转换/校验:beforeBind(参数与字面量共用)

Object beforeBind(DataType dataType, MappingEnv env, Object source) throws CriteriaException;

IntegerType.beforeBind(IntegerType.java L73-L75 → toInt L99-147)能把 Byte/Short/Long/BigDecimal/BigInteger/String/Boolean 安全转换为 int,并做溢出检查(intValueExact()、范围判断),转换失败抛业务异常而非把错误数据送进数据库:

} else if (nonNull instanceof Long) {
    final long v = (Long) nonNull;
    if (v < min || v > max) {
        throw errorHandler.apply(type, dataType, nonNull, null);  // 溢出拒绝
    }
    value = (int) v;
} else if (nonNull instanceof String) {
    value = Integer.parseInt((String) nonNull);   // 字符串数字也可转,但非法格式抛异常
} else if (nonNull instanceof Boolean) {
    value = ((Boolean) nonNull) ? 1 : 0;
}

StringType.beforeBind 则负责把数字、枚举、时间类型统一 toString(StringType.java L92-L95)。复杂类型(Json、数组、range、composite、vector)的 beforeBind 还承担序列化职责(如数组转 PG "{1,2,3}" 文本、composite 转 "(...)" 文本)。

8.4 读取后反向转换:afterGet

Object afterGet(DataType dataType, MappingEnv env, Object source) throws DataAccessException;

与 beforeBind 对称,用于 ResultSet 出参映射(如 PG TIMESTAMPTZ → OffsetDateTime、JSON 文本 → POJO)。JdbcExecutor.get(...) 方言方法读取后统一调它。

8.5 类型族标记 + 数组派生

  • 一批 marker 接口(同文件 L87-414)对类型分类:SqlInteger、SqlString、SqlText、SqlJson/SqlJsonb、SqlArray、SqlVector、SqlRange/SqlMultiRange、SqlComposite、SqlDomain 等。表达式系统据此决定允许哪些操作符(如向量距离算子只在 SqlVector 上开放)、LiteralMode.PREFERENCE 据此决定内联还是占位(_ArmyNoInjectionType 即非字符串的内置类型基类)。
  • arrayTypeOfThis() 派生数组 MappingType,支撑 int[]、String[]、float[][] 等字段的自动映射。
  • compatibleFor(dataType, targetType) 用于结果集列类型与目标 Java 类型之间的兼容匹配。

8.6 一张图理解 MappingType 的位置

            字段元数据 FieldMeta / 裸值 Class
                      │ 推断
                      ▼
                MappingType(类型语义 + 转换规则 + 类型族标记)
                 │            │                    │
          map()  │            │ beforeBind()       │ arrayTypeOfThis()
                 ▼            ▼                    ▼
          方言 DataType    规范化 Java 值       数组/复合/向量派生
                 │            │
   ┌─────────────┴────────────┴──────────────┐
   ▼                                          ▼
参数:Executor.bind → setXxx          字面量:LiteralHandler → 安全转义内联

9. namedParam / namedLiteral:批量场景的命名值

9.1 为什么需要"命名"

普通 param(type, value) 在构造语句时值就固定了。但批量操作(一条语句模板 + N 行数据)在构建模板时还没有具体值,只有"值将来从行数据的哪个字段取"的约定。命名值因此只保存一个名字(字符串 key),值在渲染每一行时通过 Function<String,Object> nameValueFunc 从当前行数据中解析。

9.2 namedParam:批量参数

SQLs.batchSingleUpdate()
    .update(User_.T)
    .set(User_.name, SQLs.namedParam(User_.name, "name"))
    .where(User_.id.equal(SQLs.namedParam(User_.id, "id")))
    .asBatchUpdate();
// SQL 模板:UPDATE users SET name = ? WHERE id = ?
// 每行数据按 "name"/"id" 取值,JDBC addBatch 逐组绑定

实现见 ArmyParamExpression.named() L78-L88(产出 ImmutableNamedNonNullParam,toString() 为 " ?:name")。渲染期逻辑在 StatementContext.appendParam L196-L220:

  • 批量模板阶段(nameValueFunc == null):只写 ? 并登记命名参数;
  • 多语句批量阶段(有 nameValueFunc):当场按名取值,转成普通 SingleParam。

namedParam 允许的值约束:namedParam 要求非 null;值可能为 null 的场景用 namedNullableParam。

9.3 namedLiteral:批量内联值

SQLs.singleInsert()
    .insertInto(Stock_.T)
    .values()
    .parens(s -> s.space(Stock_.code, SQLs.namedLiteral(StringType.INSTANCE, "code"))
                  .comma(Stock_.name, SQLs.namedLiteral(StringType.INSTANCE, "name")))
    .asInsert();
// 每行渲染:INSERT INTO stock(code,name) VALUES ('AAPL'::VARCHAR,'Apple Inc.'::VARCHAR), ...

值在渲染期通过 nameValueFunc.apply(name) 取出(StatementContext L238-L304),随后走与普通字面量完全相同的 safeLiteral 管道,安全性一致;非空版本(NamedLiteral 继承 NonNullValue)取到 null 会抛 "named literal(...) must non-null"。

特殊常量 SQLs.BATCH_NO_LITERAL / BATCH_NO_CONST(SQLs.java L288 附近,名为 $ARMY_BATCH_NO$)用于在多行 VALUES 中内联行号(1,2,3...),由 batchIndexFunc 提供。

9.4 namedParam vs namedLiteral

维度 namedParam namedLiteral
SQL 形态 ? 占位 值直接内联(带/不带类型前缀)
解析时机 执行期按行绑定 渲染期按行内联
适用语句 批量 INSERT / UPDATE / DELETE 均可 仅 VALUES INSERT(独立批量 UPDATE/DELETE 抛异常)
安全性 JDBC 参数 类型校验 + 转义,同样安全
典型用途 通用批量数据 需要内联的场景(行号、配合多值 INSERT)

10. 重要约束:namedLiteral 只能用于 INSERT

这是 Army 的硬性设计约束,不是建议。

10.1 源码中的拒绝点

独立批量语句的上下文 BatchSpecStatementContext.readCurrentRowNamedValue() L100-L118:

@Nullable
@Override
final Object readCurrentRowNamedValue(final String name) {
    final List<?> paramList = this.paramList;
    final ObjectAccessor accessor = this.accessor;
    if (paramList == null || accessor == null) {
        if (this instanceof _SimpleQueryContext) {
            String m = "simple query don't support named literal";
            return new CriteriaException(m);
        } else {
            throw _Exceptions.independentDmlDontSupportNamedValue();   // ★
        }
    }
    ...
    return accessor.get(paramList.get(this.paramIndex), name);
}

异常文案(_Exceptions.java L843-L846):

public static CriteriaException independentDmlDontSupportNamedValue() {
    String m = "Only the batch update(delete) in multi-statement context support named parameter(literal).";
    return new CriteriaException(m);
}

官方文档(docs/index.html "namedLiteral()" 一节)同样明确:

namedLiteral is designed exclusively for VALUES INSERT statements. Using it in a batch UPDATE or batch DELETE statement throws CriteriaException at runtime … If you need named values in batch UPDATE/DELETE, use SQLs.namedParam() instead.

10.2 为什么这么设计

根本原因是两种批量的执行模型不同:

  • 批量 UPDATE/DELETE:一条固定 SQL 模板(UPDATE t SET ... WHERE id = ?)+ JDBC addBatch() 多次绑定。模板只渲染一次,渲染期没有"当前行",命名字面量无处取值;值必须走 ? 在执行期逐行 setXxx。
  • 批量 VALUES INSERT:Army 逐行渲染(或一次渲染多行 VALUES),渲染每一行时都有明确的"当前行数据上下文",命名字面量可以按行解析并内联。
  • 多语句(multi-statement)场景例外:当批量 UPDATE/DELETE 处于多语句上下文中(每条语句独立完整渲染)时,accessor 存在,命名值可以解析——这正是异常文案 "in multi-statement context" 的含义。普通独立批量 UPDATE/DELETE 则直接拒绝。

10.3 记忆口诀

批量 UPDATE/DELETE 要按名传值  → namedParam(?)
批量 VALUES INSERT 要按名内联  → namedLiteral(值)

11. rowParam 与 rowLiteral:IN 列表的两种形态

用于 IN / NOT IN 右侧的集合值,同样分参数与字面量两条管道。

11.1 rowParam:展开为多个占位符

.where(User_.id.in(SQLs.rowParam(IntegerType.INSTANCE, List.of(1, 2, 3))))
// SQL: WHERE id IN (?, ?, ?)
// 参数列表登记为一个 MultiParam(3 个值)

实现:ArmyRowParamExpression.multi() L51-L63(非空集合校验)。占位符渲染 StatementContext.appendMultiParamPlaceholder() L440-L453:

private static void appendMultiParamPlaceholder(StringBuilder sqlBuilder,
                                                SqlValueParam.MultiParamValue sqlParam) {
    final int paramSize = sqlParam.columnSize();
    sqlBuilder.append(_Constant.SPACE_LEFT_PAREN);
    for (int i = 0; i < paramSize; i++) {
        if (i > 0) sqlBuilder.append(_Constant.SPACE_COMMA);
        sqlBuilder.append(SPACE_PLACEHOLDER);          // 每个元素一个 '?'
    }
    sqlBuilder.append(_Constant.SPACE_RIGHT_PAREN);
}

执行期 bindParameters 对 MultiParam.valueList() 逐个 beforeBind + bind(JdbcExecutor L1353-L1395)。

批量版本 namedRowParam(type, name, size):渲染 size 个 ?,值按名从行数据取集合。

11.2 rowLiteral:展开为多个安全内联值

.where(User_.id.in(SQLs.rowLiteral(IntegerType.INSTANCE, List.of(1, 2, 3))))
// SQL: WHERE id IN (1, 2, 3)

实现:ArmyRowLiteralExpression.AnonymousRowLiteral.appendSql() L140-L156——逐元素调用 context.appendLiteral(type, value, typeName),即每个元素都走完整的字面量安全管道:

sqlBuilder.append(_Constant.SPACE_LEFT_PAREN);
for (int i = 0; i < valueSize; i++) {
    if (i > 0) sqlBuilder.append(_Constant.SPACE_COMMA);
    context.appendLiteral(type, valueList.get(i), this.typeName);
}
sqlBuilder.append(_Constant.SPACE_RIGHT_PAREN);

11.3 两者对比与选择

维度 rowParam rowLiteral
SQL IN (?, ?, ?) IN (1, 2, 3) / IN ('a','b')
值数量 运行期可变,集合大小即占位符数 渲染期固定
计划缓存 不同长度生成不同 SQL(占位符数不同),同长度可复用 每个值组合都是不同 SQL 文本
安全性 参数级 逐元素类型校验 + 转义
适用 用户输入的动态集合(首选) 固定小集合、静态字典

两者都强制集合非空(values.isEmpty() 抛异常),从 API 层面杜绝 IN () 语法错误。


12. 与 MyBatis / Hibernate 的对比

12.1 三者绑定模型总览

维度 Army MyBatis 3 Hibernate ORM 6
值通道 param(?)/ literal(安全内联)双通道 #{}(?)/ ${}(裸拼接) JdbcParameter(?)/ JdbcLiteralFormatter(安全内联)
类型来源 编译期字段元数据 + MappingType 运行期参数对象反射 + TypeHandler 编译期实体模型 JavaType + JdbcType
取值方式 值在 Criteria 链中直接持有 / 命名 key MetaObject + OGNL 从 JavaBean/Map 反射取值 AST 中绑定 JdbcParameter + 参数索引
值转换 MappingType.beforeBind(含溢出校验) TypeHandler.setParameter JavaType.unwrap + BasicBinder.doBind
字面量字符串转义 方言级(引号翻倍/反斜杠/十六进制兜底) 无内建安全字面量通道(${} 不转义) Dialect.appendLiteral → 单引号翻倍
批量命名值 namedParam/namedLiteral 内建 @Param + 动态 SQL <foreach> Query.setParameterList / 集合参数
方言差异处理 map(ServerMeta) + 方言 Executor/Handler databaseId / 手写方言 SQL Dialect 体系 + 函数/类型描述符
SQL 构造方式 Java 类型安全 Criteria DSL XML 动态 SQL / 注解 SQL HQL/JPQL/Criteria(SQM AST)

12.2 与 MyBatis 对比

MyBatis 的参数管道(与 Army param 对应):

  • #{} 由 RawSqlSource 中的 new GenericTokenParser("#{", "}", tokenHandler) 解析为 ? 并登记 ParameterMapping;
  • 执行期 DefaultParameterHandler.setParameters() 通过 MetaObject.getValue(propertyName) 用反射从参数对象取值,再由 TypeHandler.setParameter(ps, i+1, value, jdbcType) 绑定。

这条管道同样安全,但有两个 Army 不存在的问题:

  1. ${} 裸拼接通道:TextSqlNode 用 new GenericTokenParser("${", "}", handler) 做原样文本替换,无任何转义,是 MyBatis SQL 注入的主要来源(动态表名/列名/排序字段不得不使用时,必须开发者自行白名单校验)。Army 没有等价 API——标识符来自类型安全的元数据,值只有受管的两条通道。
  2. 参数解析发生在运行期:MyBatis 的值散落在 JavaBean/Map/@Param 中,靠 MetaObject 反射 + OGNL 表达式在执行期逐个提取(DefaultParameterHandler 中大量取值分支),属性名拼错要到运行时才暴露;Army 的值在编译期由 Java 类型链持有,字段名错误直接编译失败。

Army 相对 MyBatis 的劣势:

  • MyBatis 的 ${}、#{} 文本模板极其灵活,任意复杂动态 SQL(任意片段拼接、临时加提示字)表达成本低;Army 的 DSL 有结构约束,极少数"完全动态、无法预先建模"的 SQL 写起来不如模板自由。
  • MyBatis 学习曲线平缓,XML 即 SQL,DBA 可直接参与维护;Army 需要理解 MappingType/LiteralMode 等概念。
  • MyBatis 生态与既有 Mapper 资产庞大,迁移成本低。

12.3 与 Hibernate 对比

Hibernate 的参数管道:HQL/JPQL 参数翻译为 SQM AST 中的 JdbcParameter,执行时由 BasicBinder.bind() 分派到 doBind(如整型 st.setInt),null 走 st.setNull(index, jdbcTypeCode)。与 Army param 同为 JDBC 级安全。

Hibernate 的字面量管道:由 JdbcLiteralFormatter 渲染,字符串最终走 Dialect.appendLiteral() → appendSingleQuoteEscapedString(单引号翻倍),二进制走 X'...'。Hibernate 与 Army 一样提供安全字面量通道,不存在 MyBatis ${} 式裸拼接。

两者类型系统的思路也高度相似:Hibernate 的 JavaType + JdbcType(双向描述符)≈ Army 的 MappingType(beforeBind/afterGet + map);Hibernate Dialect ≈ Army 的方言 Executor/LiteralHandler。

差异/各自优势:

点 Army Hibernate
核心抽象 轻量 SQL DSL + MappingType,无会话态实体、无脏检查 全功能 ORM:实体生命周期、一级缓存、脏检查、延迟加载、级联
SQL 可控性 生成的 SQL 与 DSL 一一对应,显式 select/join/分页 HQL 经 SQM 翻译,复杂场景生成 SQL 不完全直观,需开 SQL 日志调优
字面量控制粒度 LiteralMode 四档全局策略 + 逐值 literal/const/param 精细选择 主要由 HQL 书写形态与字面量处理策略决定,逐值控制不如 Army 直接
高级类型 PG array/range/composite/vector 均有内建 MappingType 基础 JDBC 类型强;PG 数组/复合类型需额外配置或 @JdbcTypeCode,pgvector 需第三方
重量级 模块小、启动快、无增强(无字节码织入) 功能全但体系重,启动与映射模型复杂度高
适合 要精确控制 SQL、又要类型安全的团队 需要完整对象模型与持久化上下文的团队

12.4 注入安全性横向结论

  • Army:param 协议级安全;literal 类型校验 + 方言转义 + 十六进制兜底安全;无裸拼接 API。
  • Hibernate:参数协议级安全;字面量经 Dialect.appendLiteral 转义安全;同样不鼓励裸拼接(原生 SQL 字符串拼接仍是开发者可控的风险点,但框架的受管 API 安全)。
  • MyBatis:#{} 安全;${} 不转义,是三者中唯一框架级保留的注入入口,必须靠开发者纪律(白名单)兜底。

13. 总结

  1. 两条物理隔离的通道:param 家族产出 ?,值由方言 Executor 在执行期 setXxx;literal/const 家族在渲染期由方言 LiteralHandler 安全内联。表达式层 appendParam vs appendLiteral 是分界点。
  2. 隐式绑定是显式绑定的语法糖:裸值经 Expressions.wrapRight → wrapValue,按左字段 TypeInfer 与全局 LiteralMode(DEFAULT/PREFERENCE/LITERAL/CONST)自动选择通道。
  3. MappingType 是五合一中枢:Java 类型声明、方言映射(map)、写入转换(beforeBind)、读取转换(afterGet)、类型族标记/数组派生;参数与字面量共用同一套转换语义。
  4. 注入免疫:参数靠 JDBC 协议,字面量靠"Class 推断 + instanceof 硬校验 + _ArmyNoInjectionType 标记 + 字符串转义 + 二进制十六进制兜底";Army 不提供裸拼接 API。
  5. 批量语义:namedParam 通吃批量 INSERT/UPDATE/DELETE;namedLiteral 仅限 VALUES INSERT(独立批量 UPDATE/DELETE 渲染期没有行上下文,直接抛 CriteriaException)。
  6. 集合值:rowParam 展平为 IN (?, ?, ?),rowLiteral 逐元素安全内联为 IN (1, 2, 3),均强制非空。
  7. 横向定位:比 MyBatis 少了 ${} 这个框架级注入面、参数在编译期即类型确定;与 Hibernate 同属"受管安全字面量"阵营,但更轻、SQL 可控性更强,不承担完整 ORM 的实体生命周期职责。

Army GitHub:https://github.com/PillArmy/army

posted @ 2026-10-01 15:04  zoro-army  阅读(6)  评论(0)    收藏  举报