Commit 9b12e80291b69b301f7c6a03605dc50afd6eb894
1 parent
8e4d1e70
fix: 适配 IoT 外部数据库时间函数并整理 SQL 工具
Showing
6 changed files
with
235 additions
and
39 deletions
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java
| ... | ... | @@ -47,14 +47,17 @@ public class SelectForUpdateMethod extends AbstractMethod { |
| 47 | 47 | |
| 48 | 48 | @Override |
| 49 | 49 | public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) { |
| 50 | - // 没有主键的表,无法查询首行主键 | |
| 50 | + // 1. 没有主键的表,无法查询首行主键 | |
| 51 | 51 | if (kind == Kind.FIRST_IDS && !tableInfo.havePK()) { |
| 52 | 52 | return null; |
| 53 | 53 | } |
| 54 | + // 2. 拼接 SQL:查询列 + 条件 + 排序与锁定;首行主键查询只查询主键列 | |
| 54 | 55 | String columns = kind == Kind.FIRST_IDS ? tableInfo.getKeyColumn() : sqlSelectColumns(tableInfo, true); |
| 55 | 56 | String sql = "<script>" + sqlFirst() + " SELECT " + columns + " FROM " + tableInfo.getTableName() |
| 56 | 57 | + NEWLINE + sqlWhere(tableInfo) + NEWLINE + sqlTail(tableInfo) + sqlComment() + NEWLINE + "</script>"; |
| 58 | + // 3. 使用 LockSqlSource 标记为锁定读的内置查询,供 ForUpdateInnerInterceptor 识别 | |
| 57 | 59 | SqlSource sqlSource = new LockSqlSource(kind, super.createSqlSource(configuration, sql, modelClass)); |
| 60 | + // 4. 注册 MappedStatement:首行主键查询的结果为主键类型,其它为实体(沿用表的 resultMap) | |
| 58 | 61 | if (kind == Kind.FIRST_IDS) { |
| 59 | 62 | return addSelectMappedStatementForOther(mapperClass, methodName, sqlSource, tableInfo.getKeyType()); |
| 60 | 63 | } |
| ... | ... | @@ -68,16 +71,21 @@ public class SelectForUpdateMethod extends AbstractMethod { |
| 68 | 71 | * @return WHERE 脚本 |
| 69 | 72 | */ |
| 70 | 73 | private String sqlWhere(TableInfo tableInfo) { |
| 74 | + // 实体条件:Wrapper 设置了 entity 时生效 | |
| 71 | 75 | String entityWhere = SqlScriptUtils.convertIf(tableInfo.getAllSqlWhere(true, true, true, WRAPPER_ENTITY_DOT), |
| 72 | 76 | WRAPPER_ENTITY + " != null", true); |
| 77 | + // 普通条件:整体加括号,避免 OR 条件绕过后面的主键、逻辑删除条件 | |
| 73 | 78 | String normalWhere = SqlScriptUtils.convertIf(" AND (${" + WRAPPER_DOT + "expression.normal.sqlSegment})", |
| 74 | 79 | WRAPPER_DOT + "nonEmptyOfNormal", true); |
| 80 | + // 主键:仅加锁查询、且表有主键时,按传入的主键限制,用于 Oracle 的首行锁定 | |
| 75 | 81 | String idWhere = kind == Kind.LOCK && tableInfo.havePK() ? NEWLINE + SqlScriptUtils.convertIf( |
| 76 | 82 | " AND " + tableInfo.getKeyColumn() + " = " + SqlScriptUtils.safeParam(PARAM_ID), PARAM_ID + " != null", true) : ""; |
| 83 | + // 拼接逻辑删除条件,由 <where> 去除开头多余的 AND | |
| 77 | 84 | return SqlScriptUtils.convertWhere(entityWhere + NEWLINE + normalWhere + idWhere |
| 78 | 85 | + NEWLINE + tableInfo.getLogicDeleteSql(true, true)); |
| 79 | 86 | } |
| 80 | 87 | |
| 88 | + // DONE @AI:方法内 注释;(处理:已按主键查询、加锁查询两种情况补充注释) | |
| 81 | 89 | /** |
| 82 | 90 | * 排序与锁定: |
| 83 | 91 | * 1. 加锁查询:Wrapper 的排序(未设置时不排序,避免改变数据库的加锁范围) + FOR UPDATE;首行再加 LIMIT 1 |
| ... | ... | @@ -88,19 +96,23 @@ public class SelectForUpdateMethod extends AbstractMethod { |
| 88 | 96 | */ |
| 89 | 97 | private String sqlTail(TableInfo tableInfo) { |
| 90 | 98 | String orderBy = " ${" + WRAPPER_DOT + "expression.orderBy.sqlSegment}"; |
| 99 | + // 情况一:首行主键查询,不加锁;未设置排序时按主键排序 | |
| 91 | 100 | if (kind == Kind.FIRST_IDS) { |
| 92 | 101 | return SqlScriptUtils.convertChoose(WRAPPER_DOT + "expression.orderBy.isEmpty()", |
| 93 | 102 | " ORDER BY " + tableInfo.getKeyColumn(), orderBy); |
| 94 | 103 | } |
| 104 | + // 情况二:加锁查询,只使用 Wrapper 的排序,末尾为 FOR UPDATE;首行为 LIMIT 1 FOR UPDATE | |
| 95 | 105 | return SqlScriptUtils.convertIf(orderBy, "!" + WRAPPER_DOT + "expression.orderBy.isEmpty()", true) |
| 96 | 106 | + NEWLINE + (kind == Kind.LOCK_FIRST ? "LIMIT 1 FOR UPDATE" : "FOR UPDATE"); |
| 97 | 107 | } |
| 98 | 108 | |
| 109 | + // DONE @AI:方法内 注释;(处理:已补充同名 MappedStatement 跳过注入的注释,保留 flushCache、useCache 的说明) | |
| 99 | 110 | @Override |
| 100 | 111 | protected MappedStatement addMappedStatement(Class<?> mapperClass, String id, SqlSource sqlSource, |
| 101 | 112 | SqlCommandType sqlCommandType, Class<?> parameterType, |
| 102 | 113 | String resultMap, Class<?> resultType, KeyGenerator keyGenerator, |
| 103 | 114 | String keyProperty, String keyColumn) { |
| 115 | + // 已通过 XML、注解等定义同名的 MappedStatement 时,不重复注入 | |
| 104 | 116 | String statementName = mapperClass.getName() + DOT + id; |
| 105 | 117 | if (configuration.hasStatement(statementName, false)) { |
| 106 | 118 | logger.warn(LEFT_SQ_BRACKET + statementName + "] Has been loaded by XML or SqlProvider or Mybatis's Annotation, so ignoring this injection for [" + getClass() + RIGHT_SQ_BRACKET); |
| ... | ... | @@ -145,6 +157,7 @@ public class SelectForUpdateMethod extends AbstractMethod { |
| 145 | 157 | * |
| 146 | 158 | * MyBatis Plus Join 等插件复制 MappedStatement 时保留同一个 SqlSource,标记仍然有效 |
| 147 | 159 | */ |
| 160 | + @SuppressWarnings("ClassCanBeRecord") | |
| 148 | 161 | @Getter |
| 149 | 162 | @AllArgsConstructor |
| 150 | 163 | public static class LockSqlSource implements SqlSource { | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
| 1 | 1 | package cn.iocoder.yudao.framework.mybatis.core.mapper; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.util.ObjectUtil; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; |
| 6 | 7 | import cn.iocoder.yudao.framework.common.pojo.PageParam; |
| ... | ... | @@ -151,10 +152,6 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> { |
| 151 | 152 | return selectOne(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2).eq(field3, value3)); |
| 152 | 153 | } |
| 153 | 154 | |
| 154 | - // TODO DONE @AI:能不能把现有的 selectOneForUpdate、selectFirstOneForUpdate、selectListForFirstLock、 这些,做一个整合。目前感觉有点散。。。不然有点乱;包括 cms 项目里,其实也有一些 lock;/Users/yunai/Java/yudao-cms-all/ruoyi-vue-pro-jdk25/yudao-module-cms;最好有个比较万金油的方案。。。。 | |
| 155 | - // 已整合为 selectOneForUpdate、selectListForUpdate、selectFirstOneForUpdate 三个锁定读入口(含字段重载),boot、cms 的手写 FOR UPDATE 均改为调用它们; | |
| 156 | - // 锁定子句由内置查询 SelectForUpdateMethod 生成(不再暴露 selectListForFirstLock 等接口方法),SQL Server 由 ForUpdateInnerInterceptor 改写,Oracle 的首行锁定在本类按数据库类型走多次查询。 | |
| 157 | - | |
| 158 | 155 | // ========== 锁定读:需要在事务中调用;Wrapper 不能使用 last、groupBy、having、参数别名 ========== |
| 159 | 156 | |
| 160 | 157 | /** |
| ... | ... | @@ -227,31 +224,40 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> { |
| 227 | 224 | * @param queryWrapper 查询条件 |
| 228 | 225 | * @return 实体 |
| 229 | 226 | */ |
| 227 | + // DONE @AI:方法内注释噢;(处理:已按“情况一 非 Oracle 单条锁定、情况二 Oracle 2.1~2.3 候选、加锁、重读”补充步骤注释) | |
| 230 | 228 | default T selectFirstOneForUpdate(LambdaQueryWrapper<T> queryWrapper) { |
| 229 | + // 情况一:非 Oracle,单条 LIMIT 1 FOR UPDATE 锁定首行(SQL Server 由 ForUpdateInnerInterceptor 改写) | |
| 231 | 230 | DbType dbType = JdbcUtils.getDbType(); |
| 232 | - if (!ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) { | |
| 231 | + // DONE @AI:ObjectUtils notequals any;(处理:已改为 ObjectUtils.notEqualsAny,dbType 为 null 时与原取反写法一致,均走单条锁定) | |
| 232 | + if (ObjectUtils.notEqualsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) { | |
| 233 | 233 | return CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK_FIRST, queryWrapper, null)); |
| 234 | 234 | } |
| 235 | + | |
| 236 | + // 情况二:Oracle,按“查询候选主键、按主键加锁、重新读取首行主键”重试,最多 5 次 | |
| 235 | 237 | int maxAttempts = 5; |
| 236 | 238 | for (int i = 0; i < maxAttempts; i++) { |
| 237 | - List<Object> ids = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null); | |
| 238 | - if (ids.isEmpty()) { | |
| 239 | + // DONE @AI:getFirst 是不是可以简化下;看看其它地方,是不是也有地方;(处理:候选、重读主键与返回的锁定行均改为 CollUtil.getFirst;是否锁定到候选仍按列表是否为空判断) | |
| 240 | + // 2.1 查询首行主键作为候选,不加锁;主键不为 null,取不到时即没有满足条件的记录 | |
| 241 | + Object id = CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null)); | |
| 242 | + if (id == null) { | |
| 239 | 243 | return null; |
| 240 | 244 | } |
| 241 | - Object id = ids.get(0); | |
| 242 | - // 是否锁定到候选,以列表是否为空判断:查询列均为 NULL 时,MyBatis 返回的元素为 null | |
| 245 | + // 2.2 按原条件、且主键等于候选加锁;列表为空说明候选已不满足条件,重试 | |
| 246 | + // 注意:以列表是否为空判断是否锁定到候选,不能以元素判断:查询列均为 NULL 时,MyBatis 返回的元素为 null | |
| 243 | 247 | List<T> lockRows = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, id); |
| 244 | - if (lockRows.isEmpty()) { | |
| 248 | + if (CollUtil.isEmpty(lockRows)) { | |
| 245 | 249 | continue; |
| 246 | 250 | } |
| 247 | - List<Object> latestIds = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null); | |
| 248 | - if (!latestIds.isEmpty() && Objects.equals(id, latestIds.get(0))) { | |
| 249 | - return lockRows.get(0); | |
| 251 | + // 2.3 锁定后重新读取首行主键,与候选相同才返回锁定的记录(查询列均为 NULL 时为 null);不同时重试 | |
| 252 | + Object latestId = CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null)); | |
| 253 | + if (Objects.equals(id, latestId)) { | |
| 254 | + return CollUtil.getFirst(lockRows); | |
| 250 | 255 | } |
| 251 | 256 | } |
| 252 | 257 | throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.LOCKED); |
| 253 | 258 | } |
| 254 | 259 | |
| 260 | + // DONE @AI:方法内注释噢;(处理:已补充校验、构建参数、执行内置查询三步注释) | |
| 255 | 261 | /** |
| 256 | 262 | * 执行锁定读的内置查询,见 {@link SelectForUpdateMethod} |
| 257 | 263 | * |
| ... | ... | @@ -262,18 +268,23 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> { |
| 262 | 268 | */ |
| 263 | 269 | private <E> List<E> selectListByForUpdateMethod(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper<T> queryWrapper, |
| 264 | 270 | Object id) { |
| 271 | + // 1. 校验查询条件 | |
| 265 | 272 | checkForUpdateWrapper(queryWrapper); |
| 273 | + // 2. 构建参数:查询条件、主键;首行主键查询由分页插件限制为 1 条,不查询总数 | |
| 266 | 274 | MapperMethod.ParamMap<Object> param = new MapperMethod.ParamMap<>(); |
| 267 | 275 | param.put(Constants.WRAPPER, queryWrapper); |
| 268 | 276 | param.put(SelectForUpdateMethod.PARAM_ID, id); |
| 269 | 277 | if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) { |
| 270 | 278 | param.put("page", MyBatisUtils.buildPage(0, 1, false)); |
| 271 | 279 | } |
| 280 | + // 3. 通过 SqlSession 执行内置查询:未在 Mapper 接口声明,MappedStatement 编号为 Mapper 接口名 + 方法名 | |
| 272 | 281 | MapperProxyMetadata mapperProxyMetadata = MybatisUtils.getMapperProxy(this); |
| 273 | 282 | return mapperProxyMetadata.getSqlSession().selectList( |
| 274 | 283 | mapperProxyMetadata.getMapperInterface().getName() + Constants.DOT + kind.getMethod(), param); |
| 275 | 284 | } |
| 276 | 285 | |
| 286 | + // DONE @AI:方法内注释噢;(处理:已补充 last、groupBy/having、参数别名的校验注释) | |
| 287 | + // DONE @AI:notequals;isNotEmpty 等等,减少取反;(处理:已改为 ObjectUtil.notEqual、CollUtil.isNotEmpty) | |
| 277 | 288 | /** |
| 278 | 289 | * 校验锁定读的查询条件:内置查询只使用实体条件、普通条件与排序 |
| 279 | 290 | * |
| ... | ... | @@ -283,14 +294,17 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> { |
| 283 | 294 | if (queryWrapper == null) { |
| 284 | 295 | throw new IllegalArgumentException("锁定读的查询条件不能为空"); |
| 285 | 296 | } |
| 297 | + // 校验 last:设置 last 时,Wrapper 的 SQL 片段比条件表达式多出 last 部分 | |
| 286 | 298 | MergeSegments expression = queryWrapper.getExpression(); |
| 287 | - if (!Objects.equals(queryWrapper.getSqlSegment(), expression.getSqlSegment())) { | |
| 299 | + if (ObjectUtil.notEqual(queryWrapper.getSqlSegment(), expression.getSqlSegment())) { | |
| 288 | 300 | throw new IllegalArgumentException("锁定读的查询条件不能使用 last"); |
| 289 | 301 | } |
| 290 | - if (!expression.getGroupBy().isEmpty() || !expression.getHaving().isEmpty()) { | |
| 302 | + // 校验 groupBy、having:内置查询不生成这两部分 | |
| 303 | + if (CollUtil.isNotEmpty(expression.getGroupBy()) || CollUtil.isNotEmpty(expression.getHaving())) { | |
| 291 | 304 | throw new IllegalArgumentException("锁定读的查询条件不能使用 groupBy、having"); |
| 292 | 305 | } |
| 293 | - if (!Constants.WRAPPER.equals(queryWrapper.getParamAlias())) { | |
| 306 | + // 校验参数别名:内置查询的模板固定使用 ew 作为参数名 | |
| 307 | + if (ObjectUtil.notEqual(Constants.WRAPPER, queryWrapper.getParamAlias())) { | |
| 294 | 308 | throw new IllegalArgumentException("锁定读的查询条件不能设置参数别名"); |
| 295 | 309 | } |
| 296 | 310 | } |
| ... | ... | @@ -305,7 +319,6 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> { |
| 305 | 319 | * @return 实体 |
| 306 | 320 | */ |
| 307 | 321 | default T selectFirstOne(SFunction<T, ?> field, Object value) { |
| 308 | - // 如果明确使用 MySQL 等场景,可以考虑使用 LIMIT 1 进行优化 | |
| 309 | 322 | List<T> list = selectList(new LambdaQueryWrapper<T>().eq(field, value)); |
| 310 | 323 | return CollUtil.getFirst(list); |
| 311 | 324 | } | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java
| ... | ... | @@ -15,8 +15,6 @@ import java.util.Map; |
| 15 | 15 | import java.util.Set; |
| 16 | 16 | import java.util.regex.Pattern; |
| 17 | 17 | |
| 18 | -// TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 | |
| 19 | -// TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。 | |
| 20 | 18 | /** |
| 21 | 19 | * 日期统计、逗号分隔列表关联、JSON 数组匹配、行数限制、分批删除 SQL 片段的方言工具类 |
| 22 | 20 | * |
| ... | ... | @@ -165,7 +163,6 @@ public class SqlDialectUtils { |
| 165 | 163 | * @param column 列名 |
| 166 | 164 | * @return SQL 片段 |
| 167 | 165 | */ |
| 168 | - // TODO DONE @AI:用不到的,可以暂时先去掉噢; CRM 业绩统计使用 yyyyMM,商城交易统计使用 yyyy-MM,均需保留。 | |
| 169 | 166 | public static String formatYearMonth(String column) { |
| 170 | 167 | return formatYearMonth(getDialect(), column); |
| 171 | 168 | } |
| ... | ... | @@ -196,7 +193,6 @@ public class SqlDialectUtils { |
| 196 | 193 | * @param column 列名 |
| 197 | 194 | * @return SQL 片段 |
| 198 | 195 | */ |
| 199 | - // TODO DONE @AI:用不到的,可以暂时先去掉噢; CRM 业绩统计使用 yyyyMM,商城交易统计使用 yyyy-MM,均需保留。 | |
| 200 | 196 | public static String formatYearDashMonth(String column) { |
| 201 | 197 | return formatYearDashMonth(getDialect(), column); |
| 202 | 198 | } |
| ... | ... | @@ -248,7 +244,6 @@ public class SqlDialectUtils { |
| 248 | 244 | return formatDate(dialect, column); |
| 249 | 245 | } |
| 250 | 246 | |
| 251 | - // TODO DONE @AI:用不到的,可以先清理掉噢; AI1 首页与交易统计的 String 日期消费者仍在使用,保留。 | |
| 252 | 247 | /** |
| 253 | 248 | * 按天分组;原有片段为 CAST(column AS DATE),新方言输出 yyyy-MM-dd 字符串 |
| 254 | 249 | * |
| ... | ... | @@ -286,12 +281,10 @@ public class SqlDialectUtils { |
| 286 | 281 | * @param column 列名 |
| 287 | 282 | * @return SQL 片段 |
| 288 | 283 | */ |
| 289 | - // TODO DONE @AI:多余的是不是去掉? 会员统计的 LocalDate 消费者仍在使用,保留 DATE 类型转换。 | |
| 290 | 284 | public static String castDate(String column) { |
| 291 | 285 | return castDate(getDialect(), column); |
| 292 | 286 | } |
| 293 | 287 | |
| 294 | - // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 | |
| 295 | 288 | /** |
| 296 | 289 | * 生成 LocalDate 消费者所需的日期值;Oracle 用 TRUNC 去掉时分秒。 |
| 297 | 290 | * |
| ... | ... | @@ -337,7 +330,6 @@ public class SqlDialectUtils { |
| 337 | 330 | * @param column 列名 |
| 338 | 331 | * @return SQL 片段 |
| 339 | 332 | */ |
| 340 | - // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 | |
| 341 | 333 | public static String year(String column) { |
| 342 | 334 | return year(getDialect(), column); |
| 343 | 335 | } |
| ... | ... | @@ -363,7 +355,6 @@ public class SqlDialectUtils { |
| 363 | 355 | return dayOfMonth(getDialect(), column); |
| 364 | 356 | } |
| 365 | 357 | |
| 366 | - // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 | |
| 367 | 358 | /** |
| 368 | 359 | * 按指定方言提取月内日,原路径保留 DAYOFMONTH。 |
| 369 | 360 | * | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java
| ... | ... | @@ -9,7 +9,10 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; |
| 9 | 9 | import com.zaxxer.hikari.HikariConfig; |
| 10 | 10 | import com.zaxxer.hikari.HikariDataSource; |
| 11 | 11 | import lombok.extern.slf4j.Slf4j; |
| 12 | +import org.springframework.jdbc.core.ArgumentPreparedStatementSetter; | |
| 12 | 13 | import org.springframework.jdbc.core.JdbcTemplate; |
| 14 | +import org.springframework.jdbc.core.PreparedStatementCallback; | |
| 15 | +import org.springframework.jdbc.core.PreparedStatementCreator; | |
| 13 | 16 | import org.springframework.stereotype.Component; |
| 14 | 17 | |
| 15 | 18 | /** |
| ... | ... | @@ -26,6 +29,11 @@ import org.springframework.stereotype.Component; |
| 26 | 29 | public class IotDatabaseDataRuleAction extends |
| 27 | 30 | IotDataRuleCacheableAction<IotDataSinkDatabaseConfig, JdbcTemplate> { |
| 28 | 31 | |
| 32 | + /** | |
| 33 | + * 不支持 NOW()、改用 CURRENT_TIMESTAMP 的数据库产品名,与 JDBC 驱动的 DatabaseMetaData#getDatabaseProductName 一致 | |
| 34 | + */ | |
| 35 | + private static final String[] CURRENT_TIMESTAMP_PRODUCT_NAMES = {"Oracle", "Microsoft SQL Server"}; | |
| 36 | + | |
| 29 | 37 | @Override |
| 30 | 38 | public Integer getType() { |
| 31 | 39 | return IotDataSinkTypeEnum.DATABASE.getType(); |
| ... | ... | @@ -42,17 +50,27 @@ public class IotDatabaseDataRuleAction extends |
| 42 | 50 | JdbcTemplate jdbcTemplate = getProducer(config); |
| 43 | 51 | |
| 44 | 52 | // 2. 构建并执行 INSERT SQL |
| 45 | - String sql = StrUtil.format( | |
| 46 | - "INSERT INTO {} (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())", | |
| 47 | - config.getTableName()); | |
| 48 | 53 | String messageJson = JsonUtils.toJsonString(message); |
| 49 | - jdbcTemplate.update(sql, | |
| 54 | + ArgumentPreparedStatementSetter parameterSetter = new ArgumentPreparedStatementSetter(new Object[]{ | |
| 50 | 55 | message.getId(), |
| 51 | 56 | message.getDeviceId(), |
| 52 | 57 | message.getTenantId(), |
| 53 | 58 | message.getMethod(), |
| 54 | 59 | message.getReportTime(), |
| 55 | - messageJson); | |
| 60 | + messageJson}); | |
| 61 | + // 2.1 在执行 INSERT 的同一外部连接上识别数据库产品,选择 create_time 使用的数据库时间函数 | |
| 62 | + PreparedStatementCreator statementCreator = connection -> connection.prepareStatement( | |
| 63 | + buildInsertSql(config.getTableName(), connection.getMetaData().getDatabaseProductName())); | |
| 64 | + // 2.2 Statement 由 JdbcTemplate 创建后再绑定参数:绑定或执行失败时,由 JdbcTemplate 关闭 Statement、释放连接 | |
| 65 | + PreparedStatementCallback<Integer> statementCallback = statement -> { | |
| 66 | + try { | |
| 67 | + parameterSetter.setValues(statement); | |
| 68 | + return statement.executeUpdate(); | |
| 69 | + } finally { | |
| 70 | + parameterSetter.cleanupParameters(); | |
| 71 | + } | |
| 72 | + }; | |
| 73 | + jdbcTemplate.execute(statementCreator, statementCallback); | |
| 56 | 74 | log.info("[execute][message({}) config({}) 写入数据库成功,table: {}]", |
| 57 | 75 | message.getId(), config, config.getTableName()); |
| 58 | 76 | } catch (Exception e) { |
| ... | ... | @@ -61,6 +79,22 @@ public class IotDatabaseDataRuleAction extends |
| 61 | 79 | } |
| 62 | 80 | } |
| 63 | 81 | |
| 82 | + /** | |
| 83 | + * 构建 INSERT SQL | |
| 84 | + * | |
| 85 | + * create_time 使用外部数据库的时间:Oracle、SQL Server 不支持 NOW(),使用 CURRENT_TIMESTAMP;其它数据库保持 NOW() | |
| 86 | + * | |
| 87 | + * @param tableName 已校验的表名 | |
| 88 | + * @param databaseProductName 外部数据库的产品名,即 DatabaseMetaData#getDatabaseProductName | |
| 89 | + * @return INSERT SQL | |
| 90 | + */ | |
| 91 | + private static String buildInsertSql(String tableName, String databaseProductName) { | |
| 92 | + String now = StrUtil.equalsAny(databaseProductName, CURRENT_TIMESTAMP_PRODUCT_NAMES) ? "CURRENT_TIMESTAMP" : "NOW()"; | |
| 93 | + return StrUtil.format( | |
| 94 | + "INSERT INTO {} (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, {})", | |
| 95 | + tableName, now); | |
| 96 | + } | |
| 97 | + | |
| 64 | 98 | @Override |
| 65 | 99 | protected JdbcTemplate initProducer(IotDataSinkDatabaseConfig config) throws Exception { |
| 66 | 100 | // 使用 HikariCP 连接池,HikariCP 会根据 JDBC URL 自动检测并加载对应的数据库驱动 | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 3 | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 4 | 5 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig; |
| 5 | 6 | import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; |
| 6 | 7 | import com.zaxxer.hikari.HikariDataSource; |
| 7 | 8 | import org.junit.jupiter.api.BeforeEach; |
| 8 | 9 | import org.junit.jupiter.api.Test; |
| 10 | +import org.mockito.InOrder; | |
| 9 | 11 | import org.mockito.Mock; |
| 10 | 12 | import org.mockito.MockitoAnnotations; |
| 13 | +import org.springframework.dao.DataAccessException; | |
| 11 | 14 | import org.springframework.jdbc.core.JdbcTemplate; |
| 12 | 15 | |
| 13 | 16 | import javax.sql.DataSource; |
| 17 | +import java.sql.Connection; | |
| 18 | +import java.sql.DatabaseMetaData; | |
| 19 | +import java.sql.PreparedStatement; | |
| 20 | +import java.sql.SQLException; | |
| 14 | 21 | import java.time.LocalDateTime; |
| 15 | 22 | import java.util.Arrays; |
| 23 | +import java.util.LinkedHashMap; | |
| 24 | +import java.util.Map; | |
| 16 | 25 | |
| 17 | 26 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
| 18 | 27 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 28 | +import static org.junit.jupiter.api.Assertions.assertSame; | |
| 19 | 29 | import static org.junit.jupiter.api.Assertions.assertThrows; |
| 20 | 30 | import static org.mockito.Mockito.*; |
| 21 | 31 | |
| ... | ... | @@ -26,11 +36,21 @@ import static org.mockito.Mockito.*; |
| 26 | 36 | */ |
| 27 | 37 | class IotDatabaseDataRuleActionTest { |
| 28 | 38 | |
| 39 | + private static final String TABLE_NAME = "iot_device_message"; | |
| 40 | + | |
| 29 | 41 | private IotDatabaseDataRuleAction databaseDataRuleAction; |
| 30 | 42 | |
| 31 | 43 | @Mock |
| 32 | 44 | private JdbcTemplate jdbcTemplate; |
| 33 | 45 | |
| 46 | + /** | |
| 47 | + * 外部数据库:由真实的 {@link JdbcTemplate} 管理连接、Statement 的生命周期 | |
| 48 | + */ | |
| 49 | + private DataSource externalDataSource; | |
| 50 | + private Connection externalConnection; | |
| 51 | + private DatabaseMetaData externalMetaData; | |
| 52 | + private PreparedStatement externalStatement; | |
| 53 | + | |
| 34 | 54 | @BeforeEach |
| 35 | 55 | public void setUp() { |
| 36 | 56 | MockitoAnnotations.openMocks(this); |
| ... | ... | @@ -47,17 +67,15 @@ class IotDatabaseDataRuleActionTest { |
| 47 | 67 | public void testExecute_tableNameValid() throws Exception { |
| 48 | 68 | // 准备参数:普通表名,以及 schema.table 格式 |
| 49 | 69 | for (String tableName : Arrays.asList("iot_device_message", "_message01", "iot_data.device_message")) { |
| 70 | + mockExternalDatabase("MySQL"); | |
| 50 | 71 | IotDataSinkDatabaseConfig config = buildConfig(tableName); |
| 51 | - IotDatabaseDataRuleAction action = spy(databaseDataRuleAction); | |
| 52 | - doReturn(jdbcTemplate).when(action).getProducer(same(config)); | |
| 72 | + IotDatabaseDataRuleAction action = spyAction(config); | |
| 53 | 73 | |
| 54 | 74 | // 调用 |
| 55 | 75 | action.execute(buildMessage(), config); |
| 56 | 76 | |
| 57 | 77 | // 断言:表名原样拼接,值使用参数绑定 |
| 58 | - verify(jdbcTemplate).update(eq("INSERT INTO " + tableName | |
| 59 | - + " (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())"), | |
| 60 | - any(), any(), any(), any(), any(), any()); | |
| 78 | + verify(externalConnection).prepareStatement(buildInsertSql(tableName, "NOW()")); | |
| 61 | 79 | } |
| 62 | 80 | } |
| 63 | 81 | |
| ... | ... | @@ -79,6 +97,105 @@ class IotDatabaseDataRuleActionTest { |
| 79 | 97 | } |
| 80 | 98 | |
| 81 | 99 | @Test |
| 100 | + public void testExecute_createTimeByDatabaseProduct() throws Exception { | |
| 101 | + // 准备参数:数据库产品名 -> create_time 使用的时间函数 | |
| 102 | + Map<String, String> cases = new LinkedHashMap<>(); | |
| 103 | + cases.put("Oracle", "CURRENT_TIMESTAMP"); | |
| 104 | + cases.put("Microsoft SQL Server", "CURRENT_TIMESTAMP"); | |
| 105 | + cases.put("MySQL", "NOW()"); | |
| 106 | + cases.put("PostgreSQL", "NOW()"); | |
| 107 | + cases.put("DM DBMS", "NOW()"); // 未登记的产品,保持原 NOW() | |
| 108 | + cases.put(null, "NOW()"); | |
| 109 | + for (Map.Entry<String, String> entry : cases.entrySet()) { | |
| 110 | + mockExternalDatabase(entry.getKey()); | |
| 111 | + IotDeviceMessage message = buildMessage(); | |
| 112 | + IotDataSinkDatabaseConfig config = buildConfig(TABLE_NAME); | |
| 113 | + IotDatabaseDataRuleAction action = spyAction(config); | |
| 114 | + | |
| 115 | + // 调用 | |
| 116 | + action.execute(message, config); | |
| 117 | + | |
| 118 | + // 断言:只借用一次外部连接,在同一连接上读取产品名、创建 Statement | |
| 119 | + verify(externalDataSource, times(1)).getConnection(); | |
| 120 | + InOrder inOrder = inOrder(externalConnection, externalMetaData, externalStatement); | |
| 121 | + inOrder.verify(externalConnection).getMetaData(); | |
| 122 | + inOrder.verify(externalMetaData).getDatabaseProductName(); | |
| 123 | + inOrder.verify(externalConnection).prepareStatement(buildInsertSql(TABLE_NAME, entry.getValue())); | |
| 124 | + // 断言:原 6 个参数的值、绑定方式、顺序不变 | |
| 125 | + inOrder.verify(externalStatement).setString(1, "message_01"); | |
| 126 | + inOrder.verify(externalStatement).setObject(2, 1L); | |
| 127 | + inOrder.verify(externalStatement).setObject(3, 2L); | |
| 128 | + inOrder.verify(externalStatement).setString(4, "thing.property.post"); | |
| 129 | + inOrder.verify(externalStatement).setObject(5, message.getReportTime()); | |
| 130 | + inOrder.verify(externalStatement).setString(6, JsonUtils.toJsonString(message)); | |
| 131 | + inOrder.verify(externalStatement).executeUpdate(); | |
| 132 | + // 断言:Statement、连接由 JdbcTemplate 关闭、归还 | |
| 133 | + inOrder.verify(externalStatement).close(); | |
| 134 | + inOrder.verify(externalConnection).close(); | |
| 135 | + verify(externalConnection, times(1)).prepareStatement(anyString()); | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 139 | + @Test | |
| 140 | + public void testExecute_getMetaDataFailure() throws Exception { | |
| 141 | + // 准备参数:读取外部数据库元数据失败 | |
| 142 | + mockExternalDatabase("Oracle"); | |
| 143 | + SQLException metaDataException = new SQLException("metadata failure"); | |
| 144 | + when(externalConnection.getMetaData()).thenThrow(metaDataException); | |
| 145 | + IotDataSinkDatabaseConfig config = buildConfig(TABLE_NAME); | |
| 146 | + IotDatabaseDataRuleAction action = spyAction(config); | |
| 147 | + | |
| 148 | + // 调用,并断言异常:不吞掉原始异常 | |
| 149 | + DataAccessException exception = assertThrows(DataAccessException.class, | |
| 150 | + () -> action.execute(buildMessage(), config)); | |
| 151 | + assertSame(metaDataException, exception.getCause()); | |
| 152 | + // 断言:未创建 Statement,连接被归还 | |
| 153 | + verify(externalConnection, never()).prepareStatement(anyString()); | |
| 154 | + verify(externalConnection, times(1)).close(); | |
| 155 | + verify(externalDataSource, times(1)).getConnection(); | |
| 156 | + } | |
| 157 | + | |
| 158 | + @Test | |
| 159 | + public void testExecute_bindFailure() throws Exception { | |
| 160 | + // 准备参数:绑定参数失败 | |
| 161 | + mockExternalDatabase("Microsoft SQL Server"); | |
| 162 | + SQLException bindException = new SQLException("bind failure"); | |
| 163 | + doThrow(bindException).when(externalStatement).setString(1, "message_01"); | |
| 164 | + IotDataSinkDatabaseConfig config = buildConfig(TABLE_NAME); | |
| 165 | + IotDatabaseDataRuleAction action = spyAction(config); | |
| 166 | + | |
| 167 | + // 调用,并断言异常:不吞掉原始异常 | |
| 168 | + DataAccessException exception = assertThrows(DataAccessException.class, | |
| 169 | + () -> action.execute(buildMessage(), config)); | |
| 170 | + assertSame(bindException, exception.getCause()); | |
| 171 | + // 断言:未执行 SQL,已创建的 Statement 被关闭,连接被归还 | |
| 172 | + verify(externalStatement, never()).executeUpdate(); | |
| 173 | + verify(externalStatement, times(1)).close(); | |
| 174 | + verify(externalConnection, times(1)).close(); | |
| 175 | + verify(externalDataSource, times(1)).getConnection(); | |
| 176 | + } | |
| 177 | + | |
| 178 | + @Test | |
| 179 | + public void testExecute_executeUpdateFailure() throws Exception { | |
| 180 | + // 准备参数:执行 SQL 失败 | |
| 181 | + mockExternalDatabase("PostgreSQL"); | |
| 182 | + SQLException executeException = new SQLException("execute failure"); | |
| 183 | + when(externalStatement.executeUpdate()).thenThrow(executeException); | |
| 184 | + IotDataSinkDatabaseConfig config = buildConfig(TABLE_NAME); | |
| 185 | + IotDatabaseDataRuleAction action = spyAction(config); | |
| 186 | + | |
| 187 | + // 调用,并断言异常:不吞掉原始异常 | |
| 188 | + DataAccessException exception = assertThrows(DataAccessException.class, | |
| 189 | + () -> action.execute(buildMessage(), config)); | |
| 190 | + assertSame(executeException, exception.getCause()); | |
| 191 | + // 断言:Statement 被关闭,连接被归还 | |
| 192 | + verify(externalConnection).prepareStatement(buildInsertSql(TABLE_NAME, "NOW()")); | |
| 193 | + verify(externalStatement, times(1)).close(); | |
| 194 | + verify(externalConnection, times(1)).close(); | |
| 195 | + verify(externalDataSource, times(1)).getConnection(); | |
| 196 | + } | |
| 197 | + | |
| 198 | + @Test | |
| 82 | 199 | public void testCloseProducer_whenHikari() throws Exception { |
| 83 | 200 | // 准备:底层是 HikariDataSource |
| 84 | 201 | HikariDataSource hikari = mock(HikariDataSource.class); |
| ... | ... | @@ -102,6 +219,35 @@ class IotDatabaseDataRuleActionTest { |
| 102 | 219 | verifyNoInteractions(other); |
| 103 | 220 | } |
| 104 | 221 | |
| 222 | + /** | |
| 223 | + * 模拟外部数据库:DataSource 每次返回同一个 Connection,产品名来自该 Connection 的元数据 | |
| 224 | + */ | |
| 225 | + private void mockExternalDatabase(String databaseProductName) throws SQLException { | |
| 226 | + externalDataSource = mock(DataSource.class); | |
| 227 | + externalConnection = mock(Connection.class); | |
| 228 | + externalMetaData = mock(DatabaseMetaData.class); | |
| 229 | + externalStatement = mock(PreparedStatement.class); | |
| 230 | + when(externalDataSource.getConnection()).thenReturn(externalConnection); | |
| 231 | + when(externalConnection.getMetaData()).thenReturn(externalMetaData); | |
| 232 | + when(externalMetaData.getDatabaseProductName()).thenReturn(databaseProductName); | |
| 233 | + when(externalConnection.prepareStatement(anyString())).thenReturn(externalStatement); | |
| 234 | + when(externalStatement.executeUpdate()).thenReturn(1); | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** | |
| 238 | + * 创建 Action,其 producer 为基于外部数据库的真实 {@link JdbcTemplate} | |
| 239 | + */ | |
| 240 | + private IotDatabaseDataRuleAction spyAction(IotDataSinkDatabaseConfig config) throws Exception { | |
| 241 | + IotDatabaseDataRuleAction action = spy(databaseDataRuleAction); | |
| 242 | + doReturn(new JdbcTemplate(externalDataSource)).when(action).getProducer(same(config)); | |
| 243 | + return action; | |
| 244 | + } | |
| 245 | + | |
| 246 | + private static String buildInsertSql(String tableName, String now) { | |
| 247 | + return "INSERT INTO " + tableName | |
| 248 | + + " (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, " + now + ")"; | |
| 249 | + } | |
| 250 | + | |
| 105 | 251 | private static IotDataSinkDatabaseConfig buildConfig(String tableName) { |
| 106 | 252 | IotDataSinkDatabaseConfig config = new IotDataSinkDatabaseConfig(); |
| 107 | 253 | config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iot_data"); |
| ... | ... | @@ -112,7 +258,7 @@ class IotDatabaseDataRuleActionTest { |
| 112 | 258 | } |
| 113 | 259 | |
| 114 | 260 | private static IotDeviceMessage buildMessage() { |
| 115 | - return new IotDeviceMessage().setId("message_01").setDeviceId(1L).setTenantId(1L) | |
| 261 | + return new IotDeviceMessage().setId("message_01").setDeviceId(1L).setTenantId(2L) | |
| 116 | 262 | .setMethod("thing.property.post").setReportTime(LocalDateTime.now()); |
| 117 | 263 | } |
| 118 | 264 | ... | ... |
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/cal/plan/MesCalPlanMapper.java
| ... | ... | @@ -38,7 +38,6 @@ public interface MesCalPlanMapper extends BaseMapperX<MesCalPlanDO> { |
| 38 | 38 | default List<MesCalPlanDO> selectListByStatusAndDay(Integer status, LocalDate day) { |
| 39 | 39 | return selectList(new LambdaQueryWrapperX<MesCalPlanDO>() |
| 40 | 40 | .eq(MesCalPlanDO::getStatus, status) |
| 41 | - // TODO @AI:dayend 获取下?应该有方法,这样可以 le,更统一点; | |
| 42 | 41 | .lt(MesCalPlanDO::getStartDate, day.plusDays(1).atStartOfDay()) |
| 43 | 42 | .ge(MesCalPlanDO::getEndDate, day.atStartOfDay()) |
| 44 | 43 | .orderByAsc(MesCalPlanDO::getId)); | ... | ... |