qinghuan
← JDBC 查询与资源生命周期目录

JDBC QUERIES / 补充讲义 2 / 6

单行查询、SQL 空值与逐行映射的边界

分别核对结果行数、可空数字、RowMapper 游标归属与空返回,避免将未知值、漏行和数据库故障伪装成未找到。

商户不存在、商户存在但某列为 NULL、映射器返回 null,是不同的结果。先固定查询返回几行,再检查列值与映射过程,最后决定 HTTP 和工具响应。

前置:JDBC 与连接池。本章把原资料的单行查询、可空数字和游标归属合为一个验收单元,以下代码与数据库实验均未运行。

先区分零行 一行和多行

结果基数(cardinality,返回行数)与列值是否为 SQL NULL 是两件事。指定 Class 的单列 queryForObject 期望一行一列:零行会经 nullableSingleResult 抛出 EmptyResultDataAccessException;多于一行抛出 IncorrectResultSizeDataAccessException;恰有一行而该列为 SQL NULL 时可以返回 Java null。不能把“返回 null”简单解释为“商户不存在”,也不能 catch 所有 DataAccessException 后统一返回未找到,否则数据库不可用、SQL 错误和数据完整性问题会被伪装成正常业务结果。

查商户详情时,若必须区分“没有商户”和“商户存在但某字段为空”,优先映射带非空主键的行 DTO,再把可空字段保留在 DTO 内;仅在明确的零行异常分支映射未找到,多行仍应报错并调查查询唯一性。不要用 LIMIT 1 掩盖本应唯一的业务键重复;权限过滤后的未找到对外如何表示应遵循既有安全策略,不能借本项暴露记录存在性。这是查询结果契约,不替代授权检查或数据库唯一约束。

在隔离测试表中准备零行、一行非空、一行可空字段为 SQL NULL、同一筛选条件命中两行四种夹具;分别断言参数化 queryForObject(sql, String.class, args) 的异常/返回值,再用行 DTO 验证“存在且字段为空”不被判成不存在。另模拟数据库连接失败,确认不会变成 404 或工具的 not_found。提交测试结果、实际依赖版本和四类响应映射;如果项目版本不同,先读其对应 API,不直接套用 7.0.10 行为。

可空数字不能只做装箱

ResultSet 的 getInt/getLong 在列值为 SQL NULL 时返回 0;wasNull 检查的是最近一次 getter 读取的列。因此直接把 getInt 的结果装箱为 Integer,仍不能恢复已丢失的空值信息。应在读取该数字列后、读取任何其他列之前立即检查 wasNull。Spring 的 JdbcUtils.getResultSetValue 在基本类型读取分支后也执行 wasNull 检查,但自定义 RowMapper 内直接调用 getInt 不会自动获得这段处理。

例如“座位数未知”和“座位数为零”必须在 DTO 中区分,不能把缺失值变成“没有座位”。以下辅助方法仅演示读取一个可空 INT 列;实际业务主键、授权范围、字段可空性与序列化契约仍需单独确认。

文中示例 · java
import java.sql.ResultSet;
import java.sql.SQLException;

static Integer nullableInt(ResultSet rs, String column) throws SQLException {
    int value = rs.getInt(column);
    boolean missing = rs.wasNull(); // 必须在读取其他列之前判断
    return missing ? null : Integer.valueOf(value);
}

在隔离商户测试表准备 seat_count 为 NULL、0、12 的三行,逐行调用该辅助方法并断言分别得到 null、0、12;再准备 seat_count=NULL、merchant_name 非空的一行,演示错误顺序 getInt → getString → wasNull 会判断错对象。检查最终工具 JSON 和自然语言回答仍保留未知与零的差别;若序列化会省略 null,需明确省略代表什么,不让模型补成0。

RowMapper 只读取当前行

RowMapper.mapRow 收到的 ResultSet 已定位到当前行,只负责取列和构造该行结果,不应再次调用 next()。ResultSetExtractor(结果集提取器)则处理整个结果集。把手写 JDBC 的 while(next()) 原样放进 mapRow,会破坏外层遍历职责,不能用“SQL 查到三行”推断最终 DTO 就完整。RowMapper API;ResultSetExtractor API。

另一个边界:mapRow 允许返回 null;RowMapperResultSetExtractor 会把映射所得对象加入结果列表。因此,在正常逐行且未额外截断的查询中,返回 null 不能当成“过滤掉这一行”。如果需要筛选,应明确放在 SQL 或后续显式转换步骤,并与分页、总数和权限契约一致;不能在映射器里隐式跳行。RowMapperResultSetExtractor API。

Java 最小映射片段(未运行):假定 id 是非空主键,name 的可空性由项目 DTO 契约另定;这里不推进游标、不执行查询,也不承担授权。使用项目已有依赖,不要求升级。

文中示例 · java
record ShopRow(long id, String name) {}

RowMapper<ShopRow> mapper = (rs, rowNum) ->
    new ShopRow(rs.getLong("id"), rs.getString("name"));

练习 观察游标推进与空返回

第一组:固定三个假商户,按唯一 id 排序,查询仅返回这三行。对照上面的正常映射器与在 mapRow 内额外 next() 的故障映射器;记录回调进入时的 id、返回的 id、调用次数及异常。正常组须恰好得到有序的三个 DTO;故障组的具体漏行或异常取决于错误实现,不把某种驱动异常写成普适结果。可加一个已定位的 ResultSet 测试替身,让 mapper 内 next() 立即失败,用于单测回调职责。

第二组:同一三行夹具让中间行的 mapper 返回 null,断言结果列表仍有三个位置且中间为 null,再与 SQL 显式排除该行得到两项作对照。记录物理结果行数、映射后列表长度与非空 DTO 数,避免把空返回误当过滤。不要把这项演示用于跳过无权访问的数据;授权应在查询与服务边界落实。

应记录的四个数量

同时记录 SQL 结果行数、映射回调次数、结果列表长度、非空 DTO 数。正常逐行、未额外截断时,映射器返回 null 仍占一个列表位置。驱动转换、自定义映射器与序列化策略需分别验证,不能把一个实现的表现推广到所有数据访问框架。

来源与版本

基于 Spring Framework 7.0.10 与 OpenJDK jdk-21+35,独立发布日期未核实。资料原核查分别为 2026 年 10 月 28 日 06:10–06:11 JST(行数)、18:12–18:13 JST(可空数字)及 10 月 29 日 18:05–18:06 JST(逐行映射)。来源:JdbcTemplate API、DataAccessUtils API、JdbcTemplate 源码、ResultSet 源码、JdbcUtils 源码。

固定版本实现对照