大家好,我是王有志,一个分享硬核 Java 技术的金融摸鱼侠,欢迎大家加入 Java 人自己的交流群“共同富裕的 Java 人”。
在学习完上一篇文章《MyBatis映射器:一对一关联查询》后,相信你已经掌握了如何在 MyBatis 映射器中实现一对一关联查询。那么今天我们就趁热打铁,来学习如何在 MyBatis 映射器中使用 resultMap 元素实现一对多关联查询。
数据库中的一对多关联查询
实现了查询用户订单及支付订单信息之后,老板提出了新的想法“订单明细也得加进去”。于是你开始在背后蛐蛐老板,它就不能一次性把所有需求都提出来吗?但是蛐蛐归蛐蛐,活还是得干的。
订单信息中添加查询订单明细的需求也很简单,无非是连个表的事情,这有什么难的?说干就干,于是你很快就写完了 SQL 语句:
select uo.order_id,
uo.user_id,
uo.order_no,
uo.order_price,
uo.order_status,
uo.create_date,
uo.pay_date,
oi.item_id,
oi.order_id,
oi.commodity_id,
oi.commodity_price,
oi.commodity_count
from user_order uo, order_item oi
where uo.order_id = oi.order_id
and uo.order_no = 'D202405082208045788';
但是执行完 SQL 语句之后你有点懵了,数据库的查询结果给出了 3 条数据,这与我们设想的一条订单信息带 3 条订单明细也不一样啊?
难道必须要分步查询了吗?
高阶用法:使用 collection 元素实现一对多关联查询
于是你想到是不是还可以使用 resultMap 元素来实现这种一对多的关联查询呢?终于在查阅了相关资料之后,你发现了 reusltMap 元素的子元素 collection 元素似乎可以解决这个问题。
首先我们来修改 UserOrderDO,为其组合上订单明细信息,如下:
public class UserOrderDO {
// 省略 UserOrderDO 自身的字段
/**
* 支付订单信息
*/
private PayOrderDO payOrder;
/**
* 用户订单明细
*/
private List<OrderItemDO> orderItems;
}
接着我们使用 resultMap 元素编写新的映射规则“userOrderContainOrderItemMap”,如下:
<resultMap id="userOrderContainOrderItemMap" type="com.wyz.entity.UserOrderDO" extends="BaseResultMap">
<collection property="orderItems" javaType="java.util.ArrayList" ofType="com.wyz.entity.OrderItemDO" columnPrefix="oi_">
<id property="itemId" column="item_id" jdbcType="INTEGER"/>
<result property="orderId" column="order_id" jdbcType="INTEGER"/>
<result property="commodityId" column="commodity_id" jdbcType="INTEGER"/>
<result property="commodityPrice" column="commodity_price" jdbcType="DECIMAL"/>
<result property="commodityCount" column="commodity_count" jdbcType="INTEGER"/>
</collection>
</resultMap>
这与我们使用 association 元素实现一对一关联查询非常相似,而且 collection 元素中使用的大部分的属性也都在 association 元素中出现过,唯一需要特别关注的是 collection 元素中的 ofType 属性,它与 javaType 属性组合在一起,共同声明了 UserOrderDO 对象中 orderItems 字段的类型,javaType 属性用于声明 orderItems 字段在 UserOrderDO 中的“原始”类型,即该字段为一个集合(ArrayList)类型,而 ofType 属性声明了集合中元素的类型,即该集合中存储的是 OrderItemDO 类型的元素。
接着我们来定义 UserOrderMapper 接口中的方法:
UserOrderDO selectUserOrderAndOrderItemsByOrderNo(@Param("orderNo") String orderNo);
然后我们来编写UserOrderMapper#selectUserOrderAndOrderItemsByOrderNo
方法对应的 MyBatis 映射器中的 SQL 语句:
<select id="selectUserOrderAndOrderItemsByOrderNo" resultMap="userOrderContainOrderItemMap">
select uo.order_id,
uo.user_id,
uo.order_no,
uo.order_price,
uo.order_status,
uo.create_date,
uo.pay_date,
oi.item_id as oi_item_id,
oi.order_id as oi_order_id,
oi.commodity_id as oi_commodity_id,
oi.commodity_price as oi_commodity_price,
oi.commodity_count as oi_commodity_count
from user_order uo, order_item oi
where uo.order_id = oi.order_id
and uo.order_no = #{orderNo,jdbcType=VARCHAR}
</select>
最后我们来写单元测试的代码:
public void selectUserOrderAndOrderItemsByOrderNo() {
UserOrderDO userOrder = userOrderMapper.selectUserOrderAndOrderItemsByOrderNo("D202405082208045788");
System.out.println("查询结果:");
System.out.println(JSON.toJSONString(userOrder, JSONWriter.Feature.PrettyFormat));
}
执行单元测试代码,我们来观察控制台输出的结果:
可以看到在 MyBatis 的日志中,SQL 语句查询出的结果是 3 条数据,但是在我们输出的查询结果里只有一条 UserOrderDO 的数据,而 UserOrderDO 对象的 orderItems 字段中却有 3 条数据。
这是因为 MyBatis 在处理结果集时将 3 条数据进行合并,形成一条 UserOrderDO 的数据,合并结果集的主要方法如下:
DefaultResultSetHandler#handleResultSets
DefaultResultSetHandler#handleResultSet
DefaultResultSetHandler#handleRowValues
DefaultResultSetHandler#handleRowValuesForNestedResultMap
DefaultResultSetHandler#getRowValue
DefaultResultSetHandler#applyPropertyMappings
DefaultResultSetHandler#applyNestedResultMappings
DefaultResultSetHandler#linkObjects
因为这是 MyBatis 中结果集处理的核心源码了,在后面源码分析的部分我会和大家一起学习的,所以这里我们先不细说,感兴趣的小伙伴可以自行阅读源码。
高阶用法:使用 collection 元素实现一对多嵌套查询
与 association 元素一样,除了使用关联查询外,还可以通过嵌套查询的方式实现一对多管理。
首先我们来为 OrderItemMapper 接口添加相关的查询方法:
List<OrderItemDO> selectOrderItemByOrderId(@Param("orderId") Integer orderId);
然后为其添加结果集映射规则和编写相应的 SQL 语句:
<resultMap id="BaseResultMap" type="com.wyz.entity.OrderItemDO">
<id property="itemId" column="item_id" jdbcType="INTEGER"/>
<result property="orderId" column="order_id" jdbcType="INTEGER"/>
<result property="commodityId" column="commodity_id" jdbcType="INTEGER"/>
<result property="commodityPrice" column="commodity_price" jdbcType="DECIMAL"/>
<result property="commodityCount" column="commodity_count" jdbcType="INTEGER"/>
</resultMap>
<select id="selectOrderItemByOrderId" resultMap="BaseResultMap">
select * from order_item
where order_id = #{orderId, jdbcType=INTEGER}
</select>
下面我们来处理 user_order 表相关的部分,首先是定义 UserOrderMapper 接口中的方法:
UserOrderDO selectUserOrderAndOrderItemsByOrderNoNest(@Param("orderNo") String orderNo);
接着完善映射器中对应的 SQL 语句:
<select id="selectUserOrderAndOrderItemsByOrderNoNest" resultMap="userOrderContainOrderItemNestMap">
select *
from user_order
where order_no = #{orderNo, jdbcType=VARCHAR}
</select>
我们来编写 UserOrderDO 的映射规则“userOrderContainOrderItemNestMap”,如下:
<resultMap id="userOrderContainOrderItemNestMap" type="com.wyz.entity.UserOrderDO" extends="BaseResultMap">
<collection property="orderItems"
javaType="java.util.ArrayList"
ofType="com.wyz.entity.OrderItemDO"
select="com.wyz.mapper.OrderItemMapper.selectOrderItemByOrderId"
column="{orderId=order_id}"/>
</resultMap>
最后我们编写单元测试代码,如下:
public void selectUserOrderAndOrderItemsByOrderNoNest() {
UserOrderDO userOrder = userOrderMapper.selectUserOrderAndOrderItemsByOrderNoNest("D202405082208045788");
System.out.println("查询结果:");
System.out.println(JSON.toJSONString(userOrder, JSONWriter.Feature.PrettyFormat));
}
执行单元测试可以看到如下结果:
可以看到在控制台输出的执行结果中,执行了两条 SQL 语句,分别用于查询 user_order 表的数据和 order_item 表的数据,而在数据的结果中,MyBatis 也将这些数据进行了合并。
高阶用法:多层级结果集关联查询
实现了上面的所有需求后,你的老板还是不满足,它又提出了新的想法:“为什么不能在查询用户时,把该用户所有的订单,订单明细和支付订单全部查询出来呢?”,于是你再一次在背后蛐蛐了你的老板,并埋头苦干。
有了前面的经验,你很快就想到了 resultMap 元素可以解决,无法就是多套几层罢了。为了更好的进行展示多层映射规则,我们需要补充一些数据,我在附录中提供了补充数据的 SQL 脚本,可以先添加到数据库中,
目前我们的 UserOrderDO 对象中已经组合了 PayOrderDO 和 OrderItemDO,那么我们无非就是把 UserOrderDO 组合到 UserDO 对象中,代码如下:
public class UserDO {
// 省略 UserDO 自身的字段
/**
* 用户订单
*/
private List<UserOrderDO> userOrders;
}
接着我们为 UserMapper 接口中定义方法:
UserDO selectUserByUserId(@Param("userId") Integer userId);
再来写 SQL 语句,有了前面的经验,很快就能想到联表查询一次数据库交就可以互搞定,代码如下:
<select id="selectUserByUserId" resultMap="userMap">
select u.user_id,
u.name,
u.age,
u.gender,
u.id_type,
u.id_number,
uo.order_id as uo_order_id,
uo.user_id as uo_user_id,
uo.order_no as uo_order_no,
uo.order_price as uo_order_price,
uo.order_status as uo_order_status,
uo.create_date as uo_create_date,
uo.pay_date as uo_pay_date,
po.pay_order_id as po_pay_order_id,
po.order_id as po_order_id,
po.pay_order_no as po_pay_order_no,
po.pay_amount as po_pay_amount,
po.pay_channel as po_pay_channel,
po.pay_status as po_pay_status,
po.create_date as po_create_date,
po.finish_date as po_finish_date,
oi.item_id as oi_item_id,
oi.order_id as oi_order_id,
oi.commodity_id as oi_commodity_id,
oi.commodity_price as oi_commodity_price,
oi.commodity_count as oi_commodity_count
from user u, user_order uo, pay_order po, order_item oi
where u.user_id = #{userId, jdbcType=INTEGER}
and u.user_id = uo.user_id
and uo.order_id = po.order_id
and uo.order_id = oi.order_id
</select>
下面我们开始定义映射规则“userMap”,如下:
<resultMap id="userMap" type="com.wyz.entity.UserDO">
<id property="userId" column="user_id" jdbcType="INTEGER"/>
<result property="name" column="name" jdbcType="VARCHAR"/>
<result property="age" column="age" jdbcType="INTEGER"/>
<result property="gender" column="gender" jdbcType="VARCHAR"/>
<result property="idType" column="id_type" jdbcType="INTEGER"/>
<result property="idNumber" column="id_number" jdbcType="VARCHAR"/>
<collection property="userOrders" javaType="java.util.ArrayList" ofType="com.wyz.entity.UserOrderDO" columnPrefix="uo_">
<id property="orderId" column="order_id" jdbcType="INTEGER"/>
<result property="userId" column="user_id" jdbcType="INTEGER"/>
<result property="orderNo" column="order_no" jdbcType="VARCHAR"/>
<result property="orderPrice" column="order_price" jdbcType="DECIMAL"/>
<result property="orderStatus" column="order_status" jdbcType="INTEGER"/>
<result property="createDate" column="create_date" jdbcType="DATE"/>
<result property="payDate" column="pay_date" jdbcType="DATE"/>
<association property="payOrder" javaType="com.wyz.entity.PayOrderDO" columnPrefix="po_">
<id property="payOrderId" column="pay_order_id" jdbcType="INTEGER"/>
<result property="orderId" column="order_id" jdbcType="INTEGER"/>
<result property="payOrderNo" column="pay_order_no" jdbcType="VARCHAR"/>
<result property="payAmount" column="pay_amount" jdbcType="DECIMAL"/>
<result property="payChannel" column="pay_channel" jdbcType="INTEGER"/>
<result property="payStatus" column="pay_status" jdbcType="INTEGER"/>
<result property="createDate" column="create_date" jdbcType="DATE"/>
<result property="finishDate" column="finish_date" jdbcType="DATE"/>
</association>
<collection property="orderItems" javaType="java.util.ArrayList" ofType="com.wyz.entity.OrderItemDO" columnPrefix="oi_">
<id property="itemId" column="item_id" jdbcType="INTEGER"/>
<result property="orderId" column="order_id" jdbcType="INTEGER"/>
<result property="commodityId" column="commodity_id" jdbcType="INTEGER"/>
<result property="commodityPrice" column="commodity_price" jdbcType="DECIMAL"/>
<result property="commodityCount" column="commodity_count" jdbcType="INTEGER"/>
</collection>
</collection>
</resultMap>
最后我们来搞定单元测试的代码:
public void selectUserByUserId() {
UserDO user = userMapper.selectUserByUserId(1);
System.out.println("查询结果:");
System.out.println(JSON.toJSONString(user, JSONWriter.Feature.PrettyFormat));
}
当你自信满满的执行单元测试后,控制台输出的结果却有些出乎意料:
可以看到,MyBatis 执行的 SQL 语句是正常的,输出的查询结果也是正常的,可以在最终的结果集映射上出了问题,PayOrderDO 对象和 OrderItemDO 对象并没有映射成功。
columnPrefix 属性导致的映射失败
是不是 MyBatis 不支持映射规则的多层嵌套呢?
其实不是的,在 MyBatis 中使用多层嵌套规则,且每层嵌套规则都配置了 columnPrefix 属性时,在为下层映射规则的查询字段起别名时,需要将上层的嵌套映射规则配置的 columnPrefix 属性作为前缀,然后再拼接本层的 columnPrefix 属性的配置,而在 resultMap 元素的配置中,每层只需要配置自己的前缀即可。
在上面的多层嵌套映射规则的例子中,映射规则“userMap”不需要改变,SQL 语句需要修改成如下图右侧所示的内容:
我把图中右侧的 SQL 语句粘到了这里:
<select id="selectUserByUserId" resultMap="userMap">
select u.user_id,
u.name,
u.age,
u.gender,
u.id_type,
u.id_number,
uo.order_id as uo_order_id,
uo.user_id as uo_user_id,
uo.order_no as uo_order_no,
uo.order_price as uo_order_price,
uo.order_status as uo_order_status,
uo.create_date as uo_create_date,
uo.pay_date as uo_pay_date,
po.pay_order_id as uo_po_pay_order_id,
po.order_id as uo_po_order_id,
po.pay_order_no as uo_po_pay_order_no,
po.pay_amount as uo_po_pay_amount,
po.pay_channel as uo_po_pay_channel,
po.pay_status as uo_po_pay_status,
po.create_date as uo_po_create_date,
po.finish_date as uo_po_finish_date,
oi.item_id as uo_oi_item_id,
oi.order_id as uo_oi_order_id,
oi.commodity_id as uo_oi_commodity_id,
oi.commodity_price as uo_oi_commodity_price,
oi.commodity_count as uo_oi_commodity_count
from user u, user_order uo, pay_order po, order_item oi
where u.user_id = #{userId, jdbcType=INTEGER}
and u.user_id = uo.user_id
and uo.order_id = po.order_id
and uo.order_id = oi.order_id
</select>
你可以替换掉 SQL 语句,再执行单元测试看看结果,pay_order 表和 order_item 表的数据是不是已经映射到结果里了呢?
透过源码分析 columnPrefix 属性的逻辑
不感兴趣的可以先跳过这部分内容,因为在后面的源码分析篇中,我们也会涉及到这部分内容。
注意,本文中涉及到源码的部分,我只保留了相关内容的源码,因此删减和改动的部分会非常多,我会尽量保证展示出来的源码能够清晰的解释这段逻辑。
我们先找到DefaultResultSetHandler#handleResultSets
方法的源码,部分源码如下:
public List<Object> handleResultSets(Statement stmt) throws SQLException {
final List<Object> multipleResults = new ArrayList<>();
int resultSetCount = 0;
// 获取首行数据
ResultSetWrapper rsw = getFirstResultSet(stmt);
List<ResultMap> resultMaps = mappedStatement.getResultMaps();
while (rsw != null) {
ResultMap resultMap = resultMaps.get(resultSetCount);
// 处理结果集
handleResultSet(rsw, resultMap, multipleResults, null);
// 获取下一行数据
rsw = getNextResultSet(stmt);
resultSetCount++;
}
}
DefaultResultSetHandler#handleResultSets
方法的主要功能是逐行解析结果集数据,我们接着来看第 10 行中调用的DefaultResultSetHandler#handleResultSet
方法:
private void handleResultSet(ResultSetWrapper rsw, ResultMap resultMap, List<Object> multipleResults, ResultMapping parentMapping) throws SQLException {
if (parentMapping != null) {
handleRowValues(rsw, resultMap, null, RowBounds.DEFAULT, parentMapping);
} else if (resultHandler == null) {
DefaultResultHandler defaultResultHandler = new DefaultResultHandler(objectFactory);
handleRowValues(rsw, resultMap, defaultResultHandler, rowBounds, null);
}
}
删减过后DefaultResultSetHandler#handleResultSet
方法非常简单(其实原始代码也很简单),我们不需要过多关注这个方法,直接看第 3 行和第 6 行中调用的DefaultResultSetHandler#handleRowValues
方法,部分源码如下:
public void handleRowValues(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
if (resultMap.hasNestedResultMaps()) {
handleRowValuesForNestedResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
} else {
handleRowValuesForSimpleResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
}
}
DefaultResultSetHandler#handleRowValues
方法的源码也很简。不过需要解释下第 2 行中 if 语句的调用的ResultMap#hasNestedResultMaps
方法,该方法返回 ResultMap 中 hasNestedResultMaps 字段的值,该字段的值在解析 MyBatis 映射器中的 resultMap 元素时确定,如果该 resultMap 元素定义的映射规则存在嵌套映射规则,则 hasNestedResultMaps 的值为 true,否则为 false。
对于我们使用的映射规则“userMap”来说,我们嵌套了 3 层,因此在这里的条件语句中会执行第 3 行的DefaultResultSetHandler#handleRowValuesForNestedResultMap
方法,部分源码如下:
private void handleRowValuesForNestedResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
final DefaultResultContext<Object> resultContext = new DefaultResultContext<>();
Object rowValue = previousRowValue;
while (shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() && resultSet.next()) {
final ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(resultSet, resultMap, null);
final CacheKey rowKey = createRowKey(discriminatedResultMap, rsw, null);
Object partialObject = nestedResultObjects.get(rowKey);
// 获取每行的数据
rowValue = getRowValue(rsw, discriminatedResultMap, rowKey, null, partialObject);
}
}
DefaultResultSetHandler#handleRowValuesForNestedResultMap
方法删减之后就简单很多了,该方法的主要作用是调用DefaultResultSetHandler#getRowValue
方法转换结果集数据,不过需要注意下调用DefaultResultSetHandler#getRowValue
方法时第 4 个参数,此时为 null。
我们继续向下,来看第 9 行调用的DefaultResultSetHandler#getRowValue
方法,部分源码如下:
private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, CacheKey combinedKey, String columnPrefix, Object partialObject) throws SQLException {
final String resultMapId = resultMap.getId();
Object rowValue = partialObject;
rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix);
if (rowValue != null && !hasTypeHandlerForResultObject(rsw, resultMap.getType())) {
final MetaObject metaObject = configuration.newMetaObject(rowValue);
boolean foundValues = this.useConstructorMappings;
// 处理当前层级的字段映射规则
foundValues = applyPropertyMappings(rsw, resultMap, metaObject, lazyLoader, columnPrefix) || foundValues;
// 处理嵌套的字段映射规则
foundValues = applyNestedResultMappings(rsw, resultMap, metaObject, columnPrefix, combinedKey, true) || foundValues;
}
return rowValue;
}
先来看DefaultResultSetHandler#getRowValue
方法的声明,第 4 个参数的变量名是“columnPrefix”,即我们在映射规则中配置的 columnPrefix 属性,不过在首次调用DefaultResultSetHandler#getRowValue
方法的时候 columnPrefix 参数的值为 null。
接下来我们进入第 11 行中调用的DefaultResultSetHandler#applyNestedResultMappings
方法,该方法用于处理嵌套层级的字段映射,部分源码如下:
private boolean applyNestedResultMappings(ResultSetWrapper rsw, ResultMap resultMap, MetaObject metaObject, String parentPrefix, CacheKey parentRowKey, boolean newObject) {
boolean foundValues = false;
for (ResultMapping resultMapping : resultMap.getPropertyResultMappings()) {
final String nestedResultMapId = resultMapping.getNestedResultMapId();
if (nestedResultMapId != null && resultMapping.getResultSet() == null) {
// 获取 columnPrefix
final String columnPrefix = getColumnPrefix(parentPrefix, resultMapping);
// 获取嵌套规则中的配置
final ResultMap nestedResultMap = getNestedResultMap(rsw.getResultSet(), nestedResultMapId, columnPrefix);
final CacheKey rowKey = createRowKey(nestedResultMap, rsw, columnPrefix);
final CacheKey combinedKey = combineKeys(rowKey, parentRowKey);
Object rowValue = nestedResultObjects.get(combinedKey);
// 解析嵌套规则中的结果集
if (anyNotNullColumnHasValue(resultMapping, columnPrefix, rsw)) {
rowValue = getRowValue(rsw, nestedResultMap, combinedKey, columnPrefix, rowValue);
if (rowValue != null) {
linkObjects(metaObject, resultMapping, rowValue);
foundValues = true;
}
}
}
}
return foundValues;
}
DefaultResultSetHandler#applyNestedResultMappings
负责处理嵌套映射规则中的每个字段的映射逻辑。
首先来看第 3 行 for 循环语句,ResultMap 的 propertyResultMappings 字段中存储了 resultMap 元素中每个 id 元素,result 元素,association 元素和 collection 元素的解析结果,因此这里是遍历 resultMap 元素中的每项映射规则的配置。
第 4 行中的 ResultMap 的 nestedResultMapId 字段存储了嵌套映射规则的 ID,这个 ID 是由 MyBatis 自动生成的,其形式如:com.wyz.mapper.UserMapper.mapper_resultMap[userMap]_collection[userOrders]
,存储了 resuMap 的 ID,嵌套映射规则的类型,以及该嵌套规则对应的字段名。
紧接着是第 5 行的的 if 条件语句,要求 nestedResultMapId 不为空的情况下才会执行 if 条件语句中的逻辑,也就是说只有嵌套映射规则才会执行 if 条件语句中的逻辑。
再来看第 7 行中调用的DefaultResultSetHandler#getColumnPrefix
方法,该方法用于获取 columnPrefix 属性中的配置,完成源码如下:
private String getColumnPrefix(String parentPrefix, ResultMapping resultMapping) {
final StringBuilder columnPrefixBuilder = new StringBuilder();
if (parentPrefix != null) {
columnPrefixBuilder.append(parentPrefix);
}
if (resultMapping.getColumnPrefix() != null) {
columnPrefixBuilder.append(resultMapping.getColumnPrefix());
}
return columnPrefixBuilder.length() == 0 ? null : columnPrefixBuilder.toString().toUpperCase(Locale.ENGLISH);
}
我们来分析这段源码,首先是第一次调用时 parentPrefix 的值为 null,如果此时嵌套映射规则中配置了 columnPrefix 属性,例如在解析映射规则 userMap 时,解析到了下面的配置时:
<collection property="userOrders" javaType="java.util.ArrayList" ofType="com.wyz.entity.UserOrderDO" columnPrefix="uo_">
根据源码中的逻辑,此时返回的值为“uo_”。
我们回到DefaultResultSetHandler#applyNestedResultMappings
方法中的第 15 行代码,此时会递归调用DefaultResultSetHandler#getRowValue
方法,不过此时传入的是嵌套规则中的配置。
那么后面的就很好理解了,当遍历到嵌套映射规则时,会递归调用DefaultResultSetHandler#getRowValue
方法,此时传入的 columnPrefix 参数就有了值,再次执行DefaultResultSetHandler#getColumnPrefix
方法时,就是将传入的 columnPrefix 参数与嵌套规则中 columnPrefix 属性的配置组合起来。
那么我们回到最开始的 SQL 语句与映射规则“userMap”中,当遍历到 payOrder 的嵌套映射规则时,此时的前缀应该为“uo_po_”,而遍历到 orderItems 的嵌套映射规则时,此时的前缀应该为“uo_oi_”。
高阶用法:多层级结果集嵌套查询
上面我们实现的通过 user_id 查询用户信息,用户订单信息,订单明细信息以及支付信息的功能中,除了最开始的 pay_order 表和 order_utem 表的数据无法映射外,还存在一个问题,那就是在联表查询的 SQL 语句中,查询结果是笛卡尔积的形式。
不过,由于我们的测试数据非常少,而且表结构非常简单,SQL 语句对性能的影响可以湖绿不急。不过一旦数据量上来,或者联表查询的 SQL 语句设计不合理,那么对整体性能的影响可能是灾难级的,此时与其守着一次数据库交互,倒不如拆分成多个 SQL 语句分别查询了。
说干就干,我们已经知道了如何在 resultMap 元素中嵌套子查询语句,那么改写映射规则“userMap”就非常简单了,代码如下:
<resultMap id="userNestMap" type="com.wyz.entity.UserDO" extends="BaseResultMap">
<collection property="userOrders"
javaType="java.util.ArrayList"
ofType="com.wyz.entity.UserOrderDO"
select="com.wyz.mapper.UserOrderMapper.selectUserOrderByUserId"
column="{userId=user_id}">
<association property="payOrder"
javaType="com.wyz.entity.PayOrderDO"
select="com.wyz.mapper.PayOrderMapper.selectPayOrderByOrderId"
column="{orderId=order_id}"/>
<collection property="orderItems"
javaType="java.util.ArrayList"
ofType="com.wyz.entity.OrderItemDO"
select="com.wyz.mapper.OrderItemMapper.selectOrderItemByOrderId"
column="{orderId=order_id}"/>
</collection>
</resultMap>
可以看到,新的映射规则“userNestMap”分为 3 层:
- 第 1 层是 user 表与 Java 对象 UserDO 的映射规则,直接继承了 UserMapper 的映射规则集“BaseResultMap”;
- 第 2 层是 usser_order 表与 Java 对象 UserOrderDO 的映射规则,使用了子查询
UserOrderMapper#selectUserOrderByUserId
; - 第 3 层 pay_order 表与 Java 对象 PayOrderDO,以及 order_item 表与 Java 对象 OrderItemDO 的映射规则,分别使用了子查询
PayOrderMapper#selectPayOrderByOrderId
和OrderItemMapper#selectOrderItemByOrderId
。
Tips:这里就不展示 3 个子查询方法了(反正也得改)。
当你做好“万全”的准备之后执行单元测试,控制台的输出再一次让你出乎意料:
明明写了在映射规则中写了 3 个子查询,再加上主查询 SQL 语句,应该执行 4 条 SQL 语句的,可是为什么只执行了两条语句呢?
再仔细观察控制台输出的 SQL 执行记录,你发现了最外层查询 user 表的 SQL 语句和第 2 层查询 user_order 表的 SQL 语句都执行了,只有第 3 层查询 pay_order 表和查询 order_item 表的两条 SQL 语句没有执行,难道是 MyBatis 不支持 3 层嵌套子查询?
还真是这样的,MyBatis 最多只能在一个映射规则中支持两层嵌套子查询,即只允许主查询语句“拥有”子查询语句,而子查询不能再“拥有”子查询语句。
透过源码分析多层嵌套子查询
造成这种现象的原因是因为 MyBatis 在解析 resultMap 元素时,只会解析一层嵌套的子查询语句。
来看 MyBatis 解析 resultMap 元素的源码,我们直接从XMLMapperBuilder#resultMapElements
方法入手,部分源码如下:
private void resultMapElements(List<XNode> list) {
for (XNode resultMapNode : list) {
resultMapElement(resultMapNode);
}
}
该方法用于遍历映射器文件中的所有 resultMap 元素定义的映射规则,并逐个进行解析。
接下来看第 3 行调用的XMLMapperBuilder# resultMapElement
方法:
private ResultMap resultMapElement(XNode resultMapNode, List<ResultMapping> additionalResultMappings, Class<?> enclosingType) {
Class<?> typeClass = resolveClass(type);
List<ResultMapping> resultMappings = new ArrayList<>(additionalResultMappings);
List<XNode> resultChildren = resultMapNode.getChildren();
for (XNode resultChild : resultChildren) {
resultMappings.add(buildResultMappingFromContext(resultChild, typeClass, flags));
}
}
XMLMapperBuilder# resultMapElement
方法用于遍历 resultMap 元素的所有子元素,并根据子元素类型的不同执行不同的处理逻辑(这里省略了其它类型子元素的判断逻辑和处理逻辑),如果是 id 元素,result 元素,association 元素和 collection 元素等,会调用第 6 行中的XMLMapperBuilder#buildResultMappingFromContext
方法,源码如下:
private ResultMapping buildResultMappingFromContext(XNode context, Class<?> resultType, List<ResultFlag> flags) {
String property;
if (flags.contains(ResultFlag.CONSTRUCTOR)) {
property = context.getStringAttribute("name");
} else {
property = context.getStringAttribute("property");
}
String column = context.getStringAttribute("column");
String javaType = context.getStringAttribute("javaType");
String jdbcType = context.getStringAttribute("jdbcType");
String nestedSelect = context.getStringAttribute("select");
String nestedResultMap = context.getStringAttribute("resultMap", () -> processNestedResultMappings(context, Collections.emptyList(), resultType));
String notNullColumn = context.getStringAttribute("notNullColumn");
String columnPrefix = context.getStringAttribute("columnPrefix");
String typeHandler = context.getStringAttribute("typeHandler");
String resultSet = context.getStringAttribute("resultSet");
String foreignColumn = context.getStringAttribute("foreignColumn");
boolean lazy = "lazy".equals(context.getStringAttribute("fetchType", configuration.isLazyLoadingEnabled() ? "lazy" : "eager"));
Class<?> javaTypeClass = resolveClass(javaType);
Class<? extends TypeHandler<?>> typeHandlerClass = resolveClass(typeHandler);
JdbcType jdbcTypeEnum = resolveJdbcType(jdbcType);
return builderAssistant.buildResultMapping(resultType, property, column, javaTypeClass, jdbcTypeEnum, nestedSelect, nestedResultMap, notNullColumn, columnPrefix, typeHandlerClass, flags, resultSet, foreignColumn, lazy);
}
这是XMLMapperBuilder#buildResultMappingFromContext
方法的全部源码了,先来看第 11 行中,获取了子元素中 select 属性的配置,即我们的子查询语句。
接着来看第 12 行中调用的XMLMapperBuilder#processNestedResultMappings
方法,源码如下:
private String processNestedResultMappings(XNode context, List<ResultMapping> resultMappings, Class<?> enclosingType) {
if (Arrays.asList("association", "collection", "case").contains(context.getName()) && context.getStringAttribute("select") == null) {
validateCollection(context, enclosingType);
ResultMap resultMap = resultMapElement(context, resultMappings, enclosingType);
return resultMap.getId();
}
return null;
}
在XMLMapperBuilder#processNestedResultMappings
方法中,第 2 行的 if 条件语句中,判断了子元素的类型属于
association 元素,collection 元素或 case 元素中的一种,并且没有配置 select 属性时进入条件语句中递归调用XMLMapperBuilder#resultMapElement
方法解析映射规则。
这也就是说,当 resultMap 中配置了多层级的嵌套规则时,MyBatis 会将每层规则单独解析,如果是嵌套的子查询,就不会继续向下解析了,这也就是为什么在我们的多层级嵌套子查询的映射规则“userNestMap”中,无法解析到第 3 层的嵌套子查询语句。
Tips:前面的“透过源码分析 columnPrefix 属性的逻辑”中,我们提到到嵌套映射规则,就是在这里生成的。
改写多层嵌套子查询
那么我们来改写多层嵌套子查询映射规则“userNestMap”,首先我们要做的是将 3 层嵌套子查询改成两层,那么我们需要将查询 pay_order 表的子查询与查询 order_item 表的子查询移动到查询 user_order 表的数据的映射规则中。
前面我们已经写了映射规则“userOrderContainOrderItemNestMap”并且集成了 order_item 表的子查询,并且上一篇文章《MyBatis映射器:一对一关联查询》中的映射规则“userOrderContainPayOrderNestMap”集成了 pay_order 表的子查询,那么我们将两者结合一下,不就包含了两个子查询了吗?代码如下:
<resultMap id="userOrderContainOrderItemNestMap" type="com.wyz.entity.UserOrderDO" extends="userOrderContainPayOrderNestMap">
<collection property="orderItems"
javaType="java.util.ArrayList"
ofType="com.wyz.entity.OrderItemDO"
select="com.wyz.mapper.OrderItemMapper.selectOrderItemByOrderId"
column="{orderId=order_id}"/>
</resultMap>
改写完映射规则之后,我们还要为 UserOrderMapper 接口添加一个新的接口方法,因为 user 表与 user_order 表是通过 user_id 字段进行关联的,如下:
List<UserOrderDO> selectUserOrderByUserIdNest(@Param("userId")Integer userId);
接着是 UserMapper 接口对应的映射器中的 SQL 语句,如下:
<select id="selectUserOrderByUserIdNest" resultMap="userOrderContainOrderItemNestMap">
select * from user_order where user_id = #{userId,jdbcType=INTEGER}
</select>
做完这些准备工作之后,我们就来改写映射规则“userMap”,如下:
<resultMap id="userNestMap" type="com.wyz.entity.UserDO" extends="BaseResultMap">
<collection property="userOrders"
javaType="java.util.ArrayList"
ofType="com.wyz.entity.UserOrderDO"
select="com.wyz.mapper.UserOrderMapper.selectUserOrderByUserIdNest"
column="{userId=user_id}">
</collection>
</resultMap>
最后我们执行单元测试,来观察控制台的输出:
可以看到在输出的查询语句中,比我们想象中的要多,这是因为该用户有两个订单,而我们嵌套的子查询语句只允许传入单个订单 ID,因此需要根据订单 ID 查询多次支付订单信息和订单明细信息。
当然了,你可以修改这个映射规则,允许部分简单的联表查询,以减少执行 SQL 语句的次数,减少与数据库的交互。不过我是累了,就留给大家自行实现吧~~
最后,我再补充一点 association 元素和 collection 元素中是有一个属性叫做 resultMap 的,你可以用它来引入其它的映射规则,来减少配置,这个也留给大家自行探索吧。
附录:数据补充
补充数据,用于测试多层嵌套关联查询,SQL 脚本如下:
-- 用户信息
INSERT INTO user (user_id, name, age, gender, id_type, id_number) VALUES (2, '陈二', 18, 'M', 1, '1101012000808186531');
-- 用户订单信息
INSERT INTO user_order (order_id, user_id, order_no, order_price, order_status, create_date, pay_date) VALUES (3, 2, 'D202405202033475889', 100.00, 1, '2024-05-20', '2024-05-21');
-- 支付订单信息
INSERT INTO pay_order (pay_order_id, order_id, pay_order_no, pay_amount, pay_channel, pay_status, create_date, finish_date) VALUES (3, 3, 'Z202405202033475889', 100.00, 755, 1, '2024-05-21', '2024-05-21');
-- 订单明细
INSERT INTO order_item (item_id, order_id, commodity_id, commodity_price, commodity_count) VALUES (4, 2, 350891, 77.00, 100);
INSERT INTO order_item (item_id, order_id, commodity_id, commodity_price, commodity_count) VALUES (5, 2, 330001, 220.00, 10);
INSERT INTO order_item (item_id, order_id, commodity_id, commodity_price, commodity_count) VALUES (6, 3, 330002, 100.00, 1);