Commit 8e4d1e709706c502a46cec8c801aa0eaa53bf0d6
1 parent
2f655a81
fix: 统一事务锁定读并补齐多数据库统计与 JSON 筛选
Showing
19 changed files
with
1738 additions
and
108 deletions
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java
| ... | ... | @@ -4,6 +4,7 @@ import cn.hutool.core.util.StrUtil; |
| 4 | 4 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 5 | 5 | import cn.iocoder.yudao.framework.mybatis.core.handler.DefaultDBFieldHandler; |
| 6 | 6 | import cn.iocoder.yudao.framework.mybatis.core.injector.YudaoSqlInjector; |
| 7 | +import cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor; | |
| 7 | 8 | import cn.iocoder.yudao.framework.mybatis.core.util.CaseInsensitiveResultMap; |
| 8 | 9 | import com.baomidou.mybatisplus.annotation.DbType; |
| 9 | 10 | import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer; |
| ... | ... | @@ -57,6 +58,8 @@ public class YudaoMybatisAutoConfiguration { |
| 57 | 58 | public MybatisPlusInterceptor mybatisPlusInterceptor() { |
| 58 | 59 | MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); |
| 59 | 60 | mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件 |
| 61 | + // 锁定读插件:需要位于末尾,在租户、数据权限、分页等插件改写 SQL 之后,改写 SQL Server 的行锁 | |
| 62 | + mybatisPlusInterceptor.addInnerInterceptor(new ForUpdateInnerInterceptor()); | |
| 60 | 63 | // ↓↓↓ 按需开启,可能会影响到 updateBatch 的地方:例如说文件配置管理 ↓↓↓ |
| 61 | 64 | // mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 拦截没有指定条件的 update 和 delete 语句 |
| 62 | 65 | return mybatisPlusInterceptor; | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.mybatis.core.injector; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.injector.AbstractMethod; | |
| 4 | +import com.baomidou.mybatisplus.core.metadata.TableInfo; | |
| 5 | +import com.baomidou.mybatisplus.core.toolkit.sql.SqlScriptUtils; | |
| 6 | +import lombok.AllArgsConstructor; | |
| 7 | +import lombok.Getter; | |
| 8 | +import org.apache.ibatis.executor.keygen.KeyGenerator; | |
| 9 | +import org.apache.ibatis.mapping.BoundSql; | |
| 10 | +import org.apache.ibatis.mapping.MappedStatement; | |
| 11 | +import org.apache.ibatis.mapping.SqlCommandType; | |
| 12 | +import org.apache.ibatis.mapping.SqlSource; | |
| 13 | +import org.apache.ibatis.mapping.StatementType; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 锁定读使用的内置查询,见 BaseMapperX 的 selectOneForUpdate、selectListForUpdate、selectFirstOneForUpdate 方法 | |
| 17 | + * | |
| 18 | + * 按 {@link Kind} 分为三类,均不在 Mapper 接口上声明,由 BaseMapperX 通过 SqlSession 调用: | |
| 19 | + * 1. {@link Kind#LOCK}:按原条件(可选再加主键)加锁查询,末尾为 FOR UPDATE | |
| 20 | + * 2. {@link Kind#LOCK_FIRST}:按原条件、原排序加锁查询首行,末尾为 LIMIT 1 FOR UPDATE | |
| 21 | + * 3. {@link Kind#FIRST_IDS}:按原条件、原排序(未设置时按主键)查询首行主键,行数限制由分页插件生成,不加锁; | |
| 22 | + * 仅用于 Oracle 的首行锁定(Oracle 的 FOR UPDATE 不能与 ROWNUM、FETCH FIRST 一起使用) | |
| 23 | + * | |
| 24 | + * SQL Server 不支持 FOR UPDATE、LIMIT,由 {@link cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor} | |
| 25 | + * 在所有解析 SQL 的插件之后改写;该拦截器只处理 SqlSource 为 {@link LockSqlSource} 的内置查询 | |
| 26 | + * | |
| 27 | + * 1. 条件:Wrapper 的实体条件、普通条件(整体加括号,OR 条件也均受主键限制)、逻辑删除,与 MyBatis Plus 的 selectList 一致; | |
| 28 | + * 不使用 Wrapper 的 last、groupBy、having,由调用方校验 | |
| 29 | + * 2. 列与结果:加锁查询沿用 selectList 的查询列(含 Wrapper 的 select)、resultMap;主键查询的结果为主键类型 | |
| 30 | + * 3. 缓存:每次执行前清空一级缓存、且不使用二级缓存,避免锁定前后读到缓存的结果 | |
| 31 | + * | |
| 32 | + * @author 芋道源码 | |
| 33 | + */ | |
| 34 | +public class SelectForUpdateMethod extends AbstractMethod { | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * 加锁查询的主键参数名 | |
| 38 | + */ | |
| 39 | + public static final String PARAM_ID = "id"; | |
| 40 | + | |
| 41 | + private final Kind kind; | |
| 42 | + | |
| 43 | + public SelectForUpdateMethod(Kind kind) { | |
| 44 | + super(kind.getMethod()); | |
| 45 | + this.kind = kind; | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Override | |
| 49 | + public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) { | |
| 50 | + // 没有主键的表,无法查询首行主键 | |
| 51 | + if (kind == Kind.FIRST_IDS && !tableInfo.havePK()) { | |
| 52 | + return null; | |
| 53 | + } | |
| 54 | + String columns = kind == Kind.FIRST_IDS ? tableInfo.getKeyColumn() : sqlSelectColumns(tableInfo, true); | |
| 55 | + String sql = "<script>" + sqlFirst() + " SELECT " + columns + " FROM " + tableInfo.getTableName() | |
| 56 | + + NEWLINE + sqlWhere(tableInfo) + NEWLINE + sqlTail(tableInfo) + sqlComment() + NEWLINE + "</script>"; | |
| 57 | + SqlSource sqlSource = new LockSqlSource(kind, super.createSqlSource(configuration, sql, modelClass)); | |
| 58 | + if (kind == Kind.FIRST_IDS) { | |
| 59 | + return addSelectMappedStatementForOther(mapperClass, methodName, sqlSource, tableInfo.getKeyType()); | |
| 60 | + } | |
| 61 | + return addSelectMappedStatementForTable(mapperClass, methodName, sqlSource, tableInfo); | |
| 62 | + } | |
| 63 | + | |
| 64 | + /** | |
| 65 | + * 条件:实体条件 + 普通条件(加括号) + [主键] + 逻辑删除 | |
| 66 | + * | |
| 67 | + * @param tableInfo 表信息 | |
| 68 | + * @return WHERE 脚本 | |
| 69 | + */ | |
| 70 | + private String sqlWhere(TableInfo tableInfo) { | |
| 71 | + String entityWhere = SqlScriptUtils.convertIf(tableInfo.getAllSqlWhere(true, true, true, WRAPPER_ENTITY_DOT), | |
| 72 | + WRAPPER_ENTITY + " != null", true); | |
| 73 | + String normalWhere = SqlScriptUtils.convertIf(" AND (${" + WRAPPER_DOT + "expression.normal.sqlSegment})", | |
| 74 | + WRAPPER_DOT + "nonEmptyOfNormal", true); | |
| 75 | + String idWhere = kind == Kind.LOCK && tableInfo.havePK() ? NEWLINE + SqlScriptUtils.convertIf( | |
| 76 | + " AND " + tableInfo.getKeyColumn() + " = " + SqlScriptUtils.safeParam(PARAM_ID), PARAM_ID + " != null", true) : ""; | |
| 77 | + return SqlScriptUtils.convertWhere(entityWhere + NEWLINE + normalWhere + idWhere | |
| 78 | + + NEWLINE + tableInfo.getLogicDeleteSql(true, true)); | |
| 79 | + } | |
| 80 | + | |
| 81 | + /** | |
| 82 | + * 排序与锁定: | |
| 83 | + * 1. 加锁查询:Wrapper 的排序(未设置时不排序,避免改变数据库的加锁范围) + FOR UPDATE;首行再加 LIMIT 1 | |
| 84 | + * 2. 主键查询:Wrapper 的排序;未设置时按主键排序,保证锁定前后读取的首行一致 | |
| 85 | + * | |
| 86 | + * @param tableInfo 表信息 | |
| 87 | + * @return ORDER BY 等脚本 | |
| 88 | + */ | |
| 89 | + private String sqlTail(TableInfo tableInfo) { | |
| 90 | + String orderBy = " ${" + WRAPPER_DOT + "expression.orderBy.sqlSegment}"; | |
| 91 | + if (kind == Kind.FIRST_IDS) { | |
| 92 | + return SqlScriptUtils.convertChoose(WRAPPER_DOT + "expression.orderBy.isEmpty()", | |
| 93 | + " ORDER BY " + tableInfo.getKeyColumn(), orderBy); | |
| 94 | + } | |
| 95 | + return SqlScriptUtils.convertIf(orderBy, "!" + WRAPPER_DOT + "expression.orderBy.isEmpty()", true) | |
| 96 | + + NEWLINE + (kind == Kind.LOCK_FIRST ? "LIMIT 1 FOR UPDATE" : "FOR UPDATE"); | |
| 97 | + } | |
| 98 | + | |
| 99 | + @Override | |
| 100 | + protected MappedStatement addMappedStatement(Class<?> mapperClass, String id, SqlSource sqlSource, | |
| 101 | + SqlCommandType sqlCommandType, Class<?> parameterType, | |
| 102 | + String resultMap, Class<?> resultType, KeyGenerator keyGenerator, | |
| 103 | + String keyProperty, String keyColumn) { | |
| 104 | + String statementName = mapperClass.getName() + DOT + id; | |
| 105 | + if (configuration.hasStatement(statementName, false)) { | |
| 106 | + 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); | |
| 107 | + return null; | |
| 108 | + } | |
| 109 | + // 与父类相比:flushCache 为 true、useCache 为 false | |
| 110 | + return builderAssistant.addMappedStatement(id, sqlSource, StatementType.PREPARED, sqlCommandType, | |
| 111 | + null, null, null, parameterType, resultMap, resultType, | |
| 112 | + null, true, false, false, keyGenerator, keyProperty, keyColumn, | |
| 113 | + configuration.getDatabaseId(), languageDriver, null); | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** | |
| 117 | + * 内置查询的类型 | |
| 118 | + */ | |
| 119 | + @Getter | |
| 120 | + @AllArgsConstructor | |
| 121 | + public enum Kind { | |
| 122 | + | |
| 123 | + /** | |
| 124 | + * 加锁查询:按原条件、可选的主键加锁,返回实体 | |
| 125 | + */ | |
| 126 | + LOCK("_forUpdateLock"), | |
| 127 | + /** | |
| 128 | + * 首行加锁查询:按原条件、原排序加锁第 1 条,返回实体 | |
| 129 | + */ | |
| 130 | + LOCK_FIRST("_forUpdateLockFirst"), | |
| 131 | + /** | |
| 132 | + * 首行主键查询:按原条件、原排序查询第 1 条的主键,不加锁,返回主键 | |
| 133 | + */ | |
| 134 | + FIRST_IDS("_forUpdateFirstIds"); | |
| 135 | + | |
| 136 | + /** | |
| 137 | + * 方法名,即 MappedStatement 编号中 Mapper 之后的部分 | |
| 138 | + */ | |
| 139 | + private final String method; | |
| 140 | + | |
| 141 | + } | |
| 142 | + | |
| 143 | + /** | |
| 144 | + * 内置查询的 SqlSource:标记 MappedStatement 为锁定读的内置查询,SQL 由原 SqlSource 生成 | |
| 145 | + * | |
| 146 | + * MyBatis Plus Join 等插件复制 MappedStatement 时保留同一个 SqlSource,标记仍然有效 | |
| 147 | + */ | |
| 148 | + @Getter | |
| 149 | + @AllArgsConstructor | |
| 150 | + public static class LockSqlSource implements SqlSource { | |
| 151 | + | |
| 152 | + /** | |
| 153 | + * 内置查询的类型 | |
| 154 | + */ | |
| 155 | + private final Kind kind; | |
| 156 | + /** | |
| 157 | + * 原 SqlSource | |
| 158 | + */ | |
| 159 | + private final SqlSource delegate; | |
| 160 | + | |
| 161 | + @Override | |
| 162 | + public BoundSql getBoundSql(Object parameterObject) { | |
| 163 | + return delegate.getBoundSql(parameterObject); | |
| 164 | + } | |
| 165 | + | |
| 166 | + } | |
| 167 | + | |
| 168 | +} | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java
| ... | ... | @@ -15,7 +15,8 @@ import java.util.List; |
| 15 | 15 | * 自定义 SQL 注入器 |
| 16 | 16 | * |
| 17 | 17 | * 在 MyBatis Plus Join 的 {@link MPJSqlInjector} 基础上,将 selectMaps 的结果行替换为 {@link CaseInsensitiveResultMap}, |
| 18 | - * 解决 PostgreSQL、Oracle 等数据库列标签大小写折叠,导致按固定 key 取不到值的问题 | |
| 18 | + * 解决 PostgreSQL、Oracle 等数据库列标签大小写折叠,导致按固定 key 取不到值的问题; | |
| 19 | + * 并增加锁定读使用的内置查询 {@link SelectForUpdateMethod} | |
| 19 | 20 | * |
| 20 | 21 | * @author 芋道源码 |
| 21 | 22 | */ |
| ... | ... | @@ -25,6 +26,9 @@ public class YudaoSqlInjector extends MPJSqlInjector { |
| 25 | 26 | public List<AbstractMethod> getMethodList(Configuration configuration, Class<?> mapperClass, TableInfo tableInfo) { |
| 26 | 27 | List<AbstractMethod> methods = super.getMethodList(configuration, mapperClass, tableInfo); |
| 27 | 28 | methods.replaceAll(method -> method instanceof SelectMaps ? new CaseInsensitiveSelectMaps() : method); |
| 29 | + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) { | |
| 30 | + methods.add(new SelectForUpdateMethod(kind)); | |
| 31 | + } | |
| 28 | 32 | return methods; |
| 29 | 33 | } |
| 30 | 34 | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.mybatis.core.interceptor; | |
| 2 | + | |
| 3 | +import cn.hutool.core.collection.CollUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; | |
| 5 | +import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum; | |
| 6 | +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod; | |
| 7 | +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; | |
| 8 | +import com.baomidou.mybatisplus.annotation.DbType; | |
| 9 | +import com.baomidou.mybatisplus.core.toolkit.PluginUtils; | |
| 10 | +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; | |
| 11 | +import net.sf.jsqlparser.JSQLParserException; | |
| 12 | +import net.sf.jsqlparser.expression.Expression; | |
| 13 | +import net.sf.jsqlparser.expression.LongValue; | |
| 14 | +import net.sf.jsqlparser.parser.CCJSqlParserConstants; | |
| 15 | +import net.sf.jsqlparser.parser.SimpleNode; | |
| 16 | +import net.sf.jsqlparser.parser.Token; | |
| 17 | +import net.sf.jsqlparser.schema.Table; | |
| 18 | +import net.sf.jsqlparser.statement.Statement; | |
| 19 | +import net.sf.jsqlparser.statement.select.ForMode; | |
| 20 | +import net.sf.jsqlparser.statement.select.Limit; | |
| 21 | +import net.sf.jsqlparser.statement.select.PlainSelect; | |
| 22 | +import org.apache.ibatis.executor.statement.StatementHandler; | |
| 23 | +import org.apache.ibatis.mapping.SqlSource; | |
| 24 | + | |
| 25 | +import java.sql.Connection; | |
| 26 | +import java.sql.SQLException; | |
| 27 | +import java.util.ArrayList; | |
| 28 | +import java.util.List; | |
| 29 | + | |
| 30 | +// TODO DONE @AI:能不能不这么搞,而是 mapperx 根据数据库做封装噢? | |
| 31 | +// 选择执行路径已在 BaseMapperX 按数据库封装(Oracle 首行锁定走多次查询,其它单条语句); | |
| 32 | +// SQL Server 的 WITH (UPDLOCK, ROWLOCK) 无法被 JSqlParser 解析,若在 Mapper 中直接生成,租户、数据权限等插件会解析失败, | |
| 33 | +// 因此只能在所有解析 SQL 的插件之后改写。本拦截器只处理 SelectForUpdateMethod 的内置查询,不再扫描其它 SQL。 | |
| 34 | +/** | |
| 35 | + * 锁定读的拦截器,需要位于拦截器链的末尾:在 prepare 阶段处理,此时租户、数据权限、分页等插件已完成 SQL 改写 | |
| 36 | + * | |
| 37 | + * 只处理 SqlSource 为 {@link SelectForUpdateMethod.LockSqlSource} 的内置查询,其它 SQL 不做任何处理: | |
| 38 | + * 1. 在实际执行的连接上校验事务:要求非自动提交;Oracle 首行锁定的主键查询({@link SelectForUpdateMethod.Kind#FIRST_IDS}) | |
| 39 | + * 还要求隔离级别为 READ COMMITTED,其它隔离级别下,锁定后的重新读取可能读不到其它事务已提交的记录 | |
| 40 | + * 2. SQL Server 不支持 FOR UPDATE、LIMIT:将末尾的 FOR UPDATE 改写为基表的 WITH (UPDLOCK, ROWLOCK),首行的 LIMIT 1 改写为 TOP (1); | |
| 41 | + * 只按 SQL 的 token 修改,字符串中的 for update 不会被改写;仅支持单表,不支持时直接报错,不会退化为不加锁的 SQL | |
| 42 | + * 3. Oracle 不支持 LIMIT 1 FOR UPDATE:首行加锁查询({@link SelectForUpdateMethod.Kind#LOCK_FIRST})直接报错, | |
| 43 | + * 正常情况下 BaseMapperX 已按数据库类型改为多次查询 | |
| 44 | + * | |
| 45 | + * @author 芋道源码 | |
| 46 | + */ | |
| 47 | +public class ForUpdateInnerInterceptor implements InnerInterceptor { | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * SQL Server 的行锁提示:UPDLOCK 持有更新锁到事务结束,与 FOR UPDATE 一样阻塞其它事务的加锁读与修改;ROWLOCK 尽量使用行锁 | |
| 51 | + */ | |
| 52 | + static final String SQL_SERVER_LOCK_HINTS = " WITH (UPDLOCK, ROWLOCK)"; | |
| 53 | + /** | |
| 54 | + * SQL Server 的首行限制,替代 LIMIT 1 | |
| 55 | + */ | |
| 56 | + static final String SQL_SERVER_TOP_ONE = " TOP (1)"; | |
| 57 | + | |
| 58 | + @Override | |
| 59 | + public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) { | |
| 60 | + PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh); | |
| 61 | + SqlSource sqlSource = mpSh.mappedStatement().getSqlSource(); | |
| 62 | + if (!(sqlSource instanceof SelectForUpdateMethod.LockSqlSource)) { | |
| 63 | + return; | |
| 64 | + } | |
| 65 | + SelectForUpdateMethod.Kind kind = ((SelectForUpdateMethod.LockSqlSource) sqlSource).getKind(); | |
| 66 | + // 1. 校验实际连接的事务 | |
| 67 | + checkTransaction(connection, kind); | |
| 68 | + // 2. 按实际连接的数据库类型处理:首行主键查询不加锁,无需改写 | |
| 69 | + if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) { | |
| 70 | + return; | |
| 71 | + } | |
| 72 | + DbType dbType = getDbType(connection); | |
| 73 | + if (JdbcUtils.isSQLServer(dbType)) { | |
| 74 | + PluginUtils.MPBoundSql mpBoundSql = mpSh.mPBoundSql(); | |
| 75 | + mpBoundSql.sql(rewriteForSqlServer(mpBoundSql.sql(), kind == SelectForUpdateMethod.Kind.LOCK_FIRST)); | |
| 76 | + return; | |
| 77 | + } | |
| 78 | + if (kind == SelectForUpdateMethod.Kind.LOCK_FIRST && ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) { | |
| 79 | + throw new IllegalStateException("Oracle 不支持 LIMIT 1 FOR UPDATE,首行锁定需要使用多次查询"); | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * 校验连接在事务中;首行主键查询还要求隔离级别为 READ COMMITTED | |
| 85 | + * | |
| 86 | + * @param connection 实际执行的连接 | |
| 87 | + * @param kind 内置查询的类型 | |
| 88 | + */ | |
| 89 | + static void checkTransaction(Connection connection, SelectForUpdateMethod.Kind kind) { | |
| 90 | + try { | |
| 91 | + if (connection.getAutoCommit()) { | |
| 92 | + throw new IllegalStateException("锁定读需要在事务中执行,当前连接为自动提交"); | |
| 93 | + } | |
| 94 | + if (kind != SelectForUpdateMethod.Kind.FIRST_IDS) { | |
| 95 | + return; | |
| 96 | + } | |
| 97 | + int isolation = connection.getTransactionIsolation(); | |
| 98 | + if (isolation != Connection.TRANSACTION_READ_COMMITTED) { | |
| 99 | + throw new IllegalStateException("首行锁定要求事务隔离级别为 READ COMMITTED,当前为:" + isolation); | |
| 100 | + } | |
| 101 | + } catch (SQLException e) { | |
| 102 | + throw new IllegalStateException("获取连接的事务状态失败", e); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + private static DbType getDbType(Connection connection) { | |
| 107 | + try { | |
| 108 | + return DbTypeEnum.find(connection.getMetaData().getDatabaseProductName()); | |
| 109 | + } catch (SQLException e) { | |
| 110 | + throw new IllegalStateException("获取数据库类型失败", e); | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + /** | |
| 115 | + * 将单表 SELECT 末尾的 FOR UPDATE 改写为表的 WITH (UPDLOCK, ROWLOCK);首行时将 LIMIT 1 改写为 TOP (1) | |
| 116 | + * | |
| 117 | + * @param sql SQL | |
| 118 | + * @param first 是否为首行加锁查询 | |
| 119 | + * @return 改写后的 SQL | |
| 120 | + */ | |
| 121 | + static String rewriteForSqlServer(String sql, boolean first) { | |
| 122 | + Statement statement; | |
| 123 | + try { | |
| 124 | + statement = SqlTokenUtils.parse(sql); | |
| 125 | + } catch (JSQLParserException e) { | |
| 126 | + throw new IllegalStateException("解析行锁 SQL 失败:" + sql, e); | |
| 127 | + } | |
| 128 | + // 校验:仅支持单表、无修饰的 FOR UPDATE | |
| 129 | + if (!(statement instanceof PlainSelect)) { | |
| 130 | + throw unsupported("仅支持单个 SELECT", sql); | |
| 131 | + } | |
| 132 | + PlainSelect plainSelect = (PlainSelect) statement; | |
| 133 | + if (plainSelect.getForMode() != ForMode.UPDATE || plainSelect.getForUpdateTable() != null | |
| 134 | + || plainSelect.getWait() != null || plainSelect.isNoWait() || plainSelect.isSkipLocked()) { | |
| 135 | + throw unsupported("仅支持 FOR UPDATE,不支持 OF、WAIT、NOWAIT、SKIP LOCKED 等", sql); | |
| 136 | + } | |
| 137 | + if (CollUtil.isNotEmpty(plainSelect.getWithItemsList())) { | |
| 138 | + throw unsupported("不支持 WITH", sql); | |
| 139 | + } | |
| 140 | + if (!(plainSelect.getFromItem() instanceof Table) || CollUtil.isNotEmpty(plainSelect.getJoins())) { | |
| 141 | + throw unsupported("仅支持单表", sql); | |
| 142 | + } | |
| 143 | + Table table = (Table) plainSelect.getFromItem(); | |
| 144 | + if (table.getSqlServerHints() != null || table.getIndexHint() != null || table.getSampleClause() != null | |
| 145 | + || table.getPivot() != null || table.getUnPivot() != null) { | |
| 146 | + throw unsupported("表已有提示等修饰", sql); | |
| 147 | + } | |
| 148 | + if (plainSelect.getTop() != null || plainSelect.getOffset() != null || plainSelect.getFetch() != null) { | |
| 149 | + throw unsupported("不支持 TOP、OFFSET、FETCH", sql); | |
| 150 | + } | |
| 151 | + Limit limit = plainSelect.getLimit(); | |
| 152 | + if (first ? !isLimitOne(limit) || plainSelect.getDistinct() != null : limit != null) { | |
| 153 | + throw unsupported(first ? "首行仅支持 LIMIT 1,且不支持 DISTINCT" : "不支持 LIMIT", sql); | |
| 154 | + } | |
| 155 | + SimpleNode tableNode = table.getASTNode(); | |
| 156 | + if (tableNode == null) { | |
| 157 | + throw unsupported("无法定位表", sql); | |
| 158 | + } | |
| 159 | + | |
| 160 | + // 末尾的 FOR UPDATE:表之后的 token 中,最后两个(不含分号) | |
| 161 | + List<Token> tokens = new ArrayList<>(); | |
| 162 | + for (Token token = tableNode.jjtGetLastToken().next; token != null && token.kind != CCJSqlParserConstants.EOF; | |
| 163 | + token = token.next) { | |
| 164 | + tokens.add(token); | |
| 165 | + } | |
| 166 | + while (!tokens.isEmpty() && tokens.get(tokens.size() - 1).kind == CCJSqlParserConstants.ST_SEMICOLON) { | |
| 167 | + tokens.remove(tokens.size() - 1); | |
| 168 | + } | |
| 169 | + if (tokens.size() < 2 || tokens.get(tokens.size() - 2).kind != CCJSqlParserConstants.K_FOR | |
| 170 | + || tokens.get(tokens.size() - 1).kind != CCJSqlParserConstants.K_UPDATE) { | |
| 171 | + throw unsupported("FOR UPDATE 不在末尾", sql); | |
| 172 | + } | |
| 173 | + | |
| 174 | + // 删除 FOR UPDATE,在表(含别名)之后增加提示 | |
| 175 | + int[] lineStarts = SqlTokenUtils.lineStarts(sql); | |
| 176 | + Token forToken = tokens.get(tokens.size() - 2); | |
| 177 | + Token updateToken = tokens.get(tokens.size() - 1); | |
| 178 | + int tableEnd = SqlTokenUtils.end(sql, lineStarts, tableNode.jjtGetLastToken()); | |
| 179 | + List<SqlTokenUtils.Patch> patches = new ArrayList<>(); | |
| 180 | + patches.add(new SqlTokenUtils.Patch(SqlTokenUtils.begin(sql, lineStarts, forToken), | |
| 181 | + SqlTokenUtils.end(sql, lineStarts, updateToken), "")); | |
| 182 | + patches.add(new SqlTokenUtils.Patch(tableEnd, tableEnd, SQL_SERVER_LOCK_HINTS)); | |
| 183 | + // 首行:删除 LIMIT 1,在 SELECT 之后增加 TOP (1) | |
| 184 | + if (first) { | |
| 185 | + SimpleNode limitNode = limit.getASTNode(); | |
| 186 | + SimpleNode selectNode = plainSelect.getASTNode(); | |
| 187 | + if (limitNode == null || selectNode == null | |
| 188 | + || selectNode.jjtGetFirstToken().kind != CCJSqlParserConstants.K_SELECT) { | |
| 189 | + throw unsupported("无法定位 SELECT、LIMIT", sql); | |
| 190 | + } | |
| 191 | + int selectEnd = SqlTokenUtils.end(sql, lineStarts, selectNode.jjtGetFirstToken()); | |
| 192 | + patches.add(new SqlTokenUtils.Patch(selectEnd, selectEnd, SQL_SERVER_TOP_ONE)); | |
| 193 | + patches.add(new SqlTokenUtils.Patch(SqlTokenUtils.begin(sql, lineStarts, limitNode.jjtGetFirstToken()), | |
| 194 | + SqlTokenUtils.end(sql, lineStarts, limitNode.jjtGetLastToken()), "")); | |
| 195 | + } | |
| 196 | + return SqlTokenUtils.apply(sql, patches); | |
| 197 | + } | |
| 198 | + | |
| 199 | + private static boolean isLimitOne(Limit limit) { | |
| 200 | + if (limit == null || limit.getOffset() != null) { | |
| 201 | + return false; | |
| 202 | + } | |
| 203 | + Expression rowCount = limit.getRowCount(); | |
| 204 | + return rowCount instanceof LongValue && ((LongValue) rowCount).getValue() == 1L; | |
| 205 | + } | |
| 206 | + | |
| 207 | + private static IllegalStateException unsupported(String reason, String sql) { | |
| 208 | + return new IllegalStateException("SQL Server 不支持的行锁查询(" + reason + "):" + sql); | |
| 209 | + } | |
| 210 | + | |
| 211 | +} | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.mybatis.core.interceptor; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.extension.parser.JsqlParserGlobal; | |
| 4 | +import lombok.Value; | |
| 5 | +import lombok.experimental.Accessors; | |
| 6 | +import lombok.experimental.UtilityClass; | |
| 7 | +import net.sf.jsqlparser.JSQLParserException; | |
| 8 | +import net.sf.jsqlparser.parser.CCJSqlParserUtil; | |
| 9 | +import net.sf.jsqlparser.parser.Token; | |
| 10 | +import net.sf.jsqlparser.statement.Statement; | |
| 11 | + | |
| 12 | +import java.util.ArrayList; | |
| 13 | +import java.util.Comparator; | |
| 14 | +import java.util.List; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 按 JSqlParser 的 token 位置修改原 SQL 的工具类 | |
| 18 | + * | |
| 19 | + * 只替换识别出的 token,不使用 JSqlParser 重新生成整条 SQL,保留原有的字符串、注释、提示与格式 | |
| 20 | + * | |
| 21 | + * @author 芋道源码 | |
| 22 | + */ | |
| 23 | +@UtilityClass | |
| 24 | +class SqlTokenUtils { | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * 解析 SQL,保留 token 位置 | |
| 28 | + * | |
| 29 | + * 注意:不使用 {@link JsqlParserGlobal#parse(String)},其缓存返回反序列化后的语句,不包含 token 位置,也不能被修改 | |
| 30 | + * | |
| 31 | + * @param sql SQL | |
| 32 | + * @return 语句 | |
| 33 | + * @throws JSQLParserException 解析失败 | |
| 34 | + */ | |
| 35 | + static Statement parse(String sql) throws JSQLParserException { | |
| 36 | + return CCJSqlParserUtil.parse(sql, JsqlParserGlobal.getExecutorService(), null); | |
| 37 | + } | |
| 38 | + | |
| 39 | + /** | |
| 40 | + * 计算每行的起始位置,与 JavaCC 的行号规则一致:\r\n、\r、\n 均为换行 | |
| 41 | + * | |
| 42 | + * @param sql SQL | |
| 43 | + * @return 每行的起始位置,下标为行号减 1 | |
| 44 | + */ | |
| 45 | + static int[] lineStarts(String sql) { | |
| 46 | + List<Integer> starts = new ArrayList<>(); | |
| 47 | + starts.add(0); | |
| 48 | + for (int i = 0; i < sql.length(); i++) { | |
| 49 | + char c = sql.charAt(i); | |
| 50 | + if (c == '\r' && i + 1 < sql.length() && sql.charAt(i + 1) == '\n') { | |
| 51 | + i++; | |
| 52 | + starts.add(i + 1); | |
| 53 | + } else if (c == '\r' || c == '\n') { | |
| 54 | + starts.add(i + 1); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return starts.stream().mapToInt(Integer::intValue).toArray(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + /** | |
| 61 | + * 获得 token 在 SQL 中的起始位置,并校验原文与 token 一致 | |
| 62 | + * | |
| 63 | + * @param sql SQL | |
| 64 | + * @param lineStarts {@link #lineStarts(String)} 的结果 | |
| 65 | + * @param token token | |
| 66 | + * @return 起始位置 | |
| 67 | + */ | |
| 68 | + static int begin(String sql, int[] lineStarts, Token token) { | |
| 69 | + if (token.beginLine < 1 || token.beginLine > lineStarts.length) { | |
| 70 | + throw new IllegalStateException("无法定位 SQL 中的 token:" + token.image); | |
| 71 | + } | |
| 72 | + // JavaCC 的列号从 1 开始,制表符按 1 列计算 | |
| 73 | + int offset = lineStarts[token.beginLine - 1] + token.beginColumn - 1; | |
| 74 | + if (offset < 0 || !sql.startsWith(token.image, offset)) { | |
| 75 | + throw new IllegalStateException("无法定位 SQL 中的 token:" + token.image); | |
| 76 | + } | |
| 77 | + return offset; | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** | |
| 81 | + * 获得 token 在 SQL 中的结束位置(不含) | |
| 82 | + * | |
| 83 | + * @param sql SQL | |
| 84 | + * @param lineStarts {@link #lineStarts(String)} 的结果 | |
| 85 | + * @param token token | |
| 86 | + * @return 结束位置 | |
| 87 | + */ | |
| 88 | + static int end(String sql, int[] lineStarts, Token token) { | |
| 89 | + return begin(sql, lineStarts, token) + token.image.length(); | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * 按位置替换 SQL;相同位置的相同替换只执行一次,其它重叠的替换视为错误 | |
| 94 | + * | |
| 95 | + * @param sql SQL | |
| 96 | + * @param patches 替换 | |
| 97 | + * @return 替换后的 SQL | |
| 98 | + */ | |
| 99 | + static String apply(String sql, List<Patch> patches) { | |
| 100 | + List<Patch> sorted = new ArrayList<>(patches); | |
| 101 | + sorted.sort(Comparator.comparingInt(Patch::start).thenComparingInt(Patch::end)); | |
| 102 | + List<Patch> distinct = new ArrayList<>(); | |
| 103 | + for (Patch patch : sorted) { | |
| 104 | + Patch last = distinct.isEmpty() ? null : distinct.get(distinct.size() - 1); | |
| 105 | + if (patch.equals(last)) { | |
| 106 | + continue; | |
| 107 | + } | |
| 108 | + if (last != null && patch.start() < last.end()) { | |
| 109 | + throw new IllegalStateException("SQL 修改位置重叠:" + last + "、" + patch); | |
| 110 | + } | |
| 111 | + distinct.add(patch); | |
| 112 | + } | |
| 113 | + // 从后向前替换,前面的位置不受影响 | |
| 114 | + StringBuilder builder = new StringBuilder(sql); | |
| 115 | + for (int i = distinct.size() - 1; i >= 0; i--) { | |
| 116 | + Patch patch = distinct.get(i); | |
| 117 | + builder.replace(patch.start(), patch.end(), patch.replacement()); | |
| 118 | + } | |
| 119 | + return builder.toString(); | |
| 120 | + } | |
| 121 | + | |
| 122 | + /** | |
| 123 | + * 替换:将 [start, end) 替换为 replacement;start 等于 end 时为插入 | |
| 124 | + */ | |
| 125 | + @Value | |
| 126 | + @Accessors(fluent = true) | |
| 127 | + static class Patch { | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * 起始位置 | |
| 131 | + */ | |
| 132 | + int start; | |
| 133 | + /** | |
| 134 | + * 结束位置(不含) | |
| 135 | + */ | |
| 136 | + int end; | |
| 137 | + /** | |
| 138 | + * 替换的文本 | |
| 139 | + */ | |
| 140 | + String replacement; | |
| 141 | + | |
| 142 | + } | |
| 143 | + | |
| 144 | +} | ... | ... |
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.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; | |
| 4 | 6 | import cn.iocoder.yudao.framework.common.pojo.PageParam; |
| 5 | 7 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 6 | 8 | import cn.iocoder.yudao.framework.common.pojo.SortablePageParam; |
| 7 | 9 | import cn.iocoder.yudao.framework.common.pojo.SortingField; |
| 10 | +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; | |
| 8 | 11 | import cn.iocoder.yudao.framework.common.util.object.PageUtils; |
| 12 | +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod; | |
| 9 | 13 | import cn.iocoder.yudao.framework.mybatis.core.util.CaseInsensitiveResultMap; |
| 10 | 14 | import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; |
| 11 | 15 | import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; |
| ... | ... | @@ -13,19 +17,25 @@ import com.baomidou.mybatisplus.annotation.DbType; |
| 13 | 17 | import com.baomidou.mybatisplus.core.conditions.Wrapper; |
| 14 | 18 | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| 15 | 19 | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| 20 | +import com.baomidou.mybatisplus.core.conditions.segments.MergeSegments; | |
| 16 | 21 | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| 17 | 22 | import com.baomidou.mybatisplus.core.metadata.IPage; |
| 23 | +import com.baomidou.mybatisplus.core.metadata.MapperProxyMetadata; | |
| 18 | 24 | import com.baomidou.mybatisplus.core.toolkit.Constants; |
| 25 | +import com.baomidou.mybatisplus.core.toolkit.MybatisUtils; | |
| 19 | 26 | import com.baomidou.mybatisplus.core.toolkit.support.SFunction; |
| 20 | 27 | import com.baomidou.mybatisplus.extension.toolkit.Db; |
| 21 | 28 | import com.github.yulichang.base.MPJBaseMapper; |
| 22 | 29 | import com.github.yulichang.interfaces.MPJBaseJoin; |
| 23 | 30 | import com.github.yulichang.wrapper.MPJLambdaWrapper; |
| 24 | 31 | import org.apache.ibatis.annotations.Param; |
| 32 | +import org.apache.ibatis.binding.MapperMethod; | |
| 33 | +import org.apache.ibatis.exceptions.TooManyResultsException; | |
| 25 | 34 | |
| 26 | 35 | import java.util.Collection; |
| 27 | 36 | import java.util.List; |
| 28 | 37 | import java.util.Map; |
| 38 | +import java.util.Objects; | |
| 29 | 39 | |
| 30 | 40 | /** |
| 31 | 41 | * 在 MyBatis Plus 的 BaseMapper 的基础上拓展,提供更多的能力 |
| ... | ... | @@ -141,16 +151,26 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> { |
| 141 | 151 | return selectOne(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2).eq(field3, value3)); |
| 142 | 152 | } |
| 143 | 153 | |
| 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 | + // ========== 锁定读:需要在事务中调用;Wrapper 不能使用 last、groupBy、having、参数别名 ========== | |
| 159 | + | |
| 144 | 160 | /** |
| 145 | 161 | * 获得满足条件的一条记录,并使用 FOR UPDATE 锁定。 |
| 146 | 162 | * |
| 147 | - * 注意:需要在事务中调用,否则锁会立即释放。 | |
| 163 | + * 注意:需要在事务中调用,否则锁会立即释放;多于 1 条时抛出 {@link TooManyResultsException}(已锁定的记录在事务结束时释放) | |
| 148 | 164 | * |
| 149 | 165 | * @param queryWrapper 查询条件 |
| 150 | 166 | * @return 实体 |
| 151 | 167 | */ |
| 152 | 168 | default T selectOneForUpdate(LambdaQueryWrapper<T> queryWrapper) { |
| 153 | - return selectOne(queryWrapper.last("FOR UPDATE")); | |
| 169 | + List<T> list = selectListForUpdate(queryWrapper); | |
| 170 | + if (list.size() > 1) { | |
| 171 | + throw new TooManyResultsException("Expected one result (or null) to be returned by selectOneForUpdate(), but found: " + list.size()); | |
| 172 | + } | |
| 173 | + return CollUtil.getFirst(list); | |
| 154 | 174 | } |
| 155 | 175 | |
| 156 | 176 | default T selectOneForUpdate(SFunction<T, ?> field, Object value) { |
| ... | ... | @@ -167,15 +187,112 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> { |
| 167 | 187 | } |
| 168 | 188 | |
| 169 | 189 | /** |
| 190 | + * 获得满足条件的全部记录,并使用 FOR UPDATE 锁定。 | |
| 191 | + * | |
| 192 | + * 注意:需要在事务中调用,否则锁会立即释放;返回顺序为 queryWrapper 的排序,未设置时不排序 | |
| 193 | + * | |
| 194 | + * @param queryWrapper 查询条件 | |
| 195 | + * @return 实体列表 | |
| 196 | + */ | |
| 197 | + default List<T> selectListForUpdate(LambdaQueryWrapper<T> queryWrapper) { | |
| 198 | + return selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, null); | |
| 199 | + } | |
| 200 | + | |
| 201 | + default List<T> selectListForUpdate(SFunction<T, ?> field, Object value) { | |
| 202 | + return selectListForUpdate(new LambdaQueryWrapper<T>().eq(field, value)); | |
| 203 | + } | |
| 204 | + | |
| 205 | + default List<T> selectListForUpdate(SFunction<T, ?> field, Collection<?> values) { | |
| 206 | + if (CollUtil.isEmpty(values)) { | |
| 207 | + return CollUtil.newArrayList(); | |
| 208 | + } | |
| 209 | + return selectListForUpdate(new LambdaQueryWrapper<T>().in(field, values)); | |
| 210 | + } | |
| 211 | + | |
| 212 | + /** | |
| 170 | 213 | * 获得满足条件的第 1 条记录,并使用 FOR UPDATE 锁定。 |
| 171 | 214 | * |
| 172 | - * 注意:需要在事务中调用,否则锁会立即释放。 | |
| 215 | + * 注意:需要在事务中调用,否则锁会立即释放;queryWrapper 未设置排序时,为任意一条满足条件的记录(只适合判断是否存在) | |
| 216 | + * | |
| 217 | + * Oracle 不支持 LIMIT 1 FOR UPDATE(Oracle 的 FOR UPDATE 也不能与行数限制一起使用),改为多次查询: | |
| 218 | + * 1. 按原条件、原排序查询首行主键(候选),不加锁;不存在时返回 null | |
| 219 | + * 2. 按原条件、且主键等于候选主键加锁查询,不限制行数;候选已不满足条件时重试 | |
| 220 | + * 3. 锁定后重新查询首行主键,与候选相同才返回;不同(例如其它事务已提交了排序更靠前的记录)时重试,最多 | |
| 221 | + * 5 次后抛出 {@link GlobalErrorCodeConstants#LOCKED} | |
| 222 | + * 要求实际执行的连接隔离级别为 READ COMMITTED,否则锁定后无法读到其它事务已提交的记录, | |
| 223 | + * 由 {@link cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor} 校验 | |
| 224 | + * | |
| 225 | + * 其它数据库为单条 LIMIT 1 FOR UPDATE(SQL Server 改写为 TOP (1) 与 WITH (UPDLOCK, ROWLOCK)) | |
| 173 | 226 | * |
| 174 | 227 | * @param queryWrapper 查询条件 |
| 175 | 228 | * @return 实体 |
| 176 | 229 | */ |
| 177 | 230 | default T selectFirstOneForUpdate(LambdaQueryWrapper<T> queryWrapper) { |
| 178 | - return selectOne(queryWrapper.last("LIMIT 1 FOR UPDATE")); | |
| 231 | + DbType dbType = JdbcUtils.getDbType(); | |
| 232 | + if (!ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) { | |
| 233 | + return CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK_FIRST, queryWrapper, null)); | |
| 234 | + } | |
| 235 | + int maxAttempts = 5; | |
| 236 | + for (int i = 0; i < maxAttempts; i++) { | |
| 237 | + List<Object> ids = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null); | |
| 238 | + if (ids.isEmpty()) { | |
| 239 | + return null; | |
| 240 | + } | |
| 241 | + Object id = ids.get(0); | |
| 242 | + // 是否锁定到候选,以列表是否为空判断:查询列均为 NULL 时,MyBatis 返回的元素为 null | |
| 243 | + List<T> lockRows = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, id); | |
| 244 | + if (lockRows.isEmpty()) { | |
| 245 | + continue; | |
| 246 | + } | |
| 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); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.LOCKED); | |
| 253 | + } | |
| 254 | + | |
| 255 | + /** | |
| 256 | + * 执行锁定读的内置查询,见 {@link SelectForUpdateMethod} | |
| 257 | + * | |
| 258 | + * @param kind 内置查询的类型 | |
| 259 | + * @param queryWrapper 查询条件 | |
| 260 | + * @param id 主键;仅 {@link SelectForUpdateMethod.Kind#LOCK} 使用,为 null 时不限制 | |
| 261 | + * @return 实体或主键列表 | |
| 262 | + */ | |
| 263 | + private <E> List<E> selectListByForUpdateMethod(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper<T> queryWrapper, | |
| 264 | + Object id) { | |
| 265 | + checkForUpdateWrapper(queryWrapper); | |
| 266 | + MapperMethod.ParamMap<Object> param = new MapperMethod.ParamMap<>(); | |
| 267 | + param.put(Constants.WRAPPER, queryWrapper); | |
| 268 | + param.put(SelectForUpdateMethod.PARAM_ID, id); | |
| 269 | + if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) { | |
| 270 | + param.put("page", MyBatisUtils.buildPage(0, 1, false)); | |
| 271 | + } | |
| 272 | + MapperProxyMetadata mapperProxyMetadata = MybatisUtils.getMapperProxy(this); | |
| 273 | + return mapperProxyMetadata.getSqlSession().selectList( | |
| 274 | + mapperProxyMetadata.getMapperInterface().getName() + Constants.DOT + kind.getMethod(), param); | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** | |
| 278 | + * 校验锁定读的查询条件:内置查询只使用实体条件、普通条件与排序 | |
| 279 | + * | |
| 280 | + * @param queryWrapper 查询条件 | |
| 281 | + */ | |
| 282 | + private void checkForUpdateWrapper(LambdaQueryWrapper<T> queryWrapper) { | |
| 283 | + if (queryWrapper == null) { | |
| 284 | + throw new IllegalArgumentException("锁定读的查询条件不能为空"); | |
| 285 | + } | |
| 286 | + MergeSegments expression = queryWrapper.getExpression(); | |
| 287 | + if (!Objects.equals(queryWrapper.getSqlSegment(), expression.getSqlSegment())) { | |
| 288 | + throw new IllegalArgumentException("锁定读的查询条件不能使用 last"); | |
| 289 | + } | |
| 290 | + if (!expression.getGroupBy().isEmpty() || !expression.getHaving().isEmpty()) { | |
| 291 | + throw new IllegalArgumentException("锁定读的查询条件不能使用 groupBy、having"); | |
| 292 | + } | |
| 293 | + if (!Constants.WRAPPER.equals(queryWrapper.getParamAlias())) { | |
| 294 | + throw new IllegalArgumentException("锁定读的查询条件不能设置参数别名"); | |
| 295 | + } | |
| 179 | 296 | } |
| 180 | 297 | |
| 181 | 298 | /** | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java
| ... | ... | @@ -22,7 +22,9 @@ import java.util.regex.Pattern; |
| 22 | 22 | * |
| 23 | 23 | * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库 |
| 24 | 24 | * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、 |
| 25 | - * 达梦、神通、人大金仓、openGauss、GaussDB、瀚高、MogDB 等)保持原有 SQL 片段;完全未识别的数据库抛出异常 | |
| 25 | + * 达梦、神通、人大金仓、openGauss、GaussDB、瀚高、MogDB 等)保持原有 SQL 片段;完全未识别的数据库抛出异常。 | |
| 26 | + * 例外:JSON 数组匹配中,瀚高原生驱动、人大金仓使用 PostgreSQL 片段,H2 使用严格的平面 Long 数组片段, | |
| 27 | + * 见 {@link #jsonArrayContains(String, boolean)} | |
| 26 | 28 | * 3. 参数只能是代码中固定的列名,经过白名单校验;格式均为常量,业务值必须继续使用 #{} 绑定 |
| 27 | 29 | * |
| 28 | 30 | * 在 XML 或注解中使用:${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@formatDate('create_time')} |
| ... | ... | @@ -63,6 +65,50 @@ public class SqlDialectUtils { |
| 63 | 65 | new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); |
| 64 | 66 | |
| 65 | 67 | /** |
| 68 | + * 数据源与 JSON 方言的缓存;与 {@link #DIALECT_CACHE} 分开,瀚高、人大金仓、H2 仅 JSON 片段使用单独的写法 | |
| 69 | + */ | |
| 70 | + private static final Map<DataSource, Dialect> JSON_DIALECT_CACHE = | |
| 71 | + new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * 瀚高原生驱动 URL 的前缀 | |
| 75 | + */ | |
| 76 | + private static final String HIGHGO_URL_PREFIX = "jdbc:highgo:"; | |
| 77 | + | |
| 78 | + /** | |
| 79 | + * Oracle 平面 Long / null 数组的整体格式,TAB、LF、CR 需先替换为空格: | |
| 80 | + * 元素为 0、不含前导零的 1~19 位整数或小写 null,元素两侧可有空格;字符串、嵌套数组、对象、小数、指数、空元素均不匹配 | |
| 81 | + */ | |
| 82 | + static final String ORACLE_LONG_ARRAY_PATTERN = | |
| 83 | + "^ *\\[ *((0|-?[1-9][0-9]{0,18}|null) *(, *(0|-?[1-9][0-9]{0,18}|null) *)*)?\\] *$"; | |
| 84 | + | |
| 85 | + /** | |
| 86 | + * H2 平面 Long / null 数组的整体格式,规则同 {@link #ORACLE_LONG_ARRAY_PATTERN} | |
| 87 | + * | |
| 88 | + * 注意:H2 的 REGEXP_LIKE 使用 Java 正则,重复的捕获组按递归匹配,长数组(例如 1400 个元素)会栈溢出, | |
| 89 | + * 因此使用占有量词(*+)与非捕获组;Oracle 的 POSIX 正则不支持该语法,两者不能混用 | |
| 90 | + */ | |
| 91 | + static final String H2_LONG_ARRAY_PATTERN = | |
| 92 | + "^ *+\\[ *+(?:(?:0|-?[1-9][0-9]{0,18}|null) *+(?:, *+(?:0|-?[1-9][0-9]{0,18}|null) *+)*+)?\\] *+$"; | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * 超出 Long 范围的 19 位整数:负数超过 -9223372036854775808,或正数超过 9223372036854775807 | |
| 96 | + * | |
| 97 | + * 只使用分组、字符类与固定次数,Oracle 的 POSIX 正则与 H2 的 Java 正则语义一致,两者共用 | |
| 98 | + */ | |
| 99 | + static final String LONG_OVERFLOW_PATTERN = "(^|[^0-9])" + greaterDigitsPattern("9223372036854775808") | |
| 100 | + + "|(^|[^-0-9])9223372036854775808"; | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * SQL Server 校验整个 XML(<r><i>元素</i>...</r>)为平面 Long / null 数组的 XQuery: | |
| 104 | + * 存在元素,且每个元素去掉两侧空白后非空,并且为 null,或转为 xs:long 再转回文本与原文完全相同(拒绝前导零、溢出等)。 | |
| 105 | + * 转换失败时 cast as xs:long? 返回空序列,string() 后为空串,与原文不等,因此非法元素会使整体不匹配 | |
| 106 | + */ | |
| 107 | + private static final String SQL_SERVER_LONG_ARRAY_XQUERY = "/r[i and not(i[string-length(string(xs:token(string(.)))) = 0" | |
| 108 | + + " or (string(xs:token(string(.))) != \"null\"" | |
| 109 | + + " and string(xs:token(string(.))) != string((string(xs:token(string(.))) cast as xs:long?)))])]"; | |
| 110 | + | |
| 111 | + /** | |
| 66 | 112 | * 方言 |
| 67 | 113 | */ |
| 68 | 114 | enum Dialect { |
| ... | ... | @@ -73,7 +119,11 @@ public class SqlDialectUtils { |
| 73 | 119 | ORIGINAL, |
| 74 | 120 | POSTGRESQL, |
| 75 | 121 | ORACLE, |
| 76 | - SQLSERVER | |
| 122 | + SQLSERVER, | |
| 123 | + /** | |
| 124 | + * H2:仅 JSON 片段使用,见 {@link #resolveJsonDialect(DatabaseMetaData)};其它片段的 H2 仍为 {@link #ORIGINAL} | |
| 125 | + */ | |
| 126 | + H2 | |
| 77 | 127 | |
| 78 | 128 | } |
| 79 | 129 | |
| ... | ... | @@ -355,8 +405,11 @@ public class SqlDialectUtils { |
| 355 | 405 | * 平均成交周期(天):两个时间之间的完整天数(向零截断)求平均,保留 1 位小数(截断),无数据时为 0 |
| 356 | 406 | * |
| 357 | 407 | * 原有片段为 COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, begin, end)), 1), 0)。 |
| 358 | - * 注意:仅 PostgreSQL 输出新片段;Oracle、SQL Server 的完整天数表达式依赖实际列类型(DATE / TIMESTAMP / datetime2 / date 等), | |
| 359 | - * 尚未依据实际 DDL 在真实数据库验收,因此保持原有片段 | |
| 408 | + * 仅适用于不带时区的日期时间列(LocalDateTime 契约);带时区的 TIMESTAMP WITH TIME ZONE、datetimeoffset 不在适用范围内 | |
| 409 | + * | |
| 410 | + * 1. Oracle:两列均升格为 TIMESTAMP(9),DATE、TIMESTAMP(0~9) 均不丢失小数秒,INTERVAL 的 DAY 部分即向零截断的完整天数 | |
| 411 | + * 2. SQL Server 2012+:DATEDIFF(DAY) 按日期边界计数,按时刻修正为完整天数;先转 datetime2(7) 再取 time(7), | |
| 412 | + * 兼容 date、datetime、datetime2;AVG 使用 DECIMAL 避免整数平均,ROUND(..., 1, 1) 为截断 | |
| 360 | 413 | * |
| 361 | 414 | * @param beginColumn 开始时间列名 |
| 362 | 415 | * @param endColumn 结束时间列名 |
| ... | ... | @@ -367,7 +420,7 @@ public class SqlDialectUtils { |
| 367 | 420 | } |
| 368 | 421 | |
| 369 | 422 | /** |
| 370 | - * 按指定方言生成平均完整天数;未验收的数据库继续使用原表达式。 | |
| 423 | + * 按指定方言生成平均完整天数;原有路径继续使用原表达式。 | |
| 371 | 424 | * |
| 372 | 425 | * @param dialect 已识别的数据库方言 |
| 373 | 426 | * @param beginColumn 代码中固定的开始时间列名 |
| ... | ... | @@ -377,11 +430,26 @@ public class SqlDialectUtils { |
| 377 | 430 | static String customerDealCycle(Dialect dialect, String beginColumn, String endColumn) { |
| 378 | 431 | checkColumn(beginColumn); |
| 379 | 432 | checkColumn(endColumn); |
| 380 | - if (dialect == Dialect.POSTGRESQL) { | |
| 381 | - return "COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP) - CAST(" | |
| 382 | - + beginColumn + " AS TIMESTAMP))) AS NUMERIC)), 1), 0)"; | |
| 433 | + switch (dialect) { | |
| 434 | + case POSTGRESQL: | |
| 435 | + return "COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP) - CAST(" | |
| 436 | + + beginColumn + " AS TIMESTAMP))) AS NUMERIC)), 1), 0)"; | |
| 437 | + case ORACLE: | |
| 438 | + return "COALESCE(TRUNC(AVG(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP(9)) - CAST(" | |
| 439 | + + beginColumn + " AS TIMESTAMP(9))))), 1), 0)"; | |
| 440 | + case SQLSERVER: { | |
| 441 | + String beginTime = "CAST(CAST(" + beginColumn + " AS datetime2(7)) AS time(7))"; | |
| 442 | + String endTime = "CAST(CAST(" + endColumn + " AS datetime2(7)) AS time(7))"; | |
| 443 | + String days = "DATEDIFF(DAY, " + beginColumn + ", " + endColumn + ")"; | |
| 444 | + // 正向时,结束时刻早于开始时刻说明最后一天不完整;反向时同理,均向零修正 1 天 | |
| 445 | + return "COALESCE(ROUND(AVG(CAST((CASE WHEN " + endColumn + " >= " + beginColumn | |
| 446 | + + " THEN " + days + " - CASE WHEN " + endTime + " < " + beginTime + " THEN 1 ELSE 0 END" | |
| 447 | + + " ELSE " + days + " + CASE WHEN " + endTime + " > " + beginTime + " THEN 1 ELSE 0 END END)" | |
| 448 | + + " AS DECIMAL(19, 4))), 1, 1), 0)"; | |
| 449 | + } | |
| 450 | + default: | |
| 451 | + return "COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, " + beginColumn + ", " + endColumn + ")), 1), 0)"; | |
| 383 | 452 | } |
| 384 | - return "COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, " + beginColumn + ", " + endColumn + ")), 1), 0)"; | |
| 385 | 453 | } |
| 386 | 454 | |
| 387 | 455 | // ========== 其它 ========== |
| ... | ... | @@ -458,32 +526,130 @@ public class SqlDialectUtils { |
| 458 | 526 | * |
| 459 | 527 | * 1. 原有片段:wrapArray 为 false 时为 JSON_CONTAINS(column, {0}),参数为 ID 的数字文本; |
| 460 | 528 | * 为 true 时为 JSON_CONTAINS(column, JSON_ARRAY({0})),参数为 Long |
| 461 | - * 2. PostgreSQL:CAST(column AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT)),兼容 jsonb、json 与 JSON 文本列; | |
| 462 | - * 按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配 | |
| 463 | - * 3. Oracle、SQL Server 及其它数据库保持原有片段,是否可用未经验收 | |
| 529 | + * 2. PostgreSQL、瀚高(原生驱动 jdbc:highgo:)、人大金仓:CAST(column AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT)), | |
| 530 | + * 兼容 jsonb、json 与 JSON 文本列;按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配 | |
| 531 | + * 3. Oracle、SQL Server 2012+、H2:仅支持平面 Long / null 数组(应用以 List<Long> 写入的数据),元素两侧允许空白; | |
| 532 | + * 整个数组含字符串、嵌套数组、对象、小数、空元素、超出 Long 范围等非法元素时整体不匹配,不等价于通用 JSON_CONTAINS。 | |
| 533 | + * 参数为 null 时匹配数组中的 null 元素,与原有 JSON_CONTAINS(column, JSON_ARRAY(NULL)) 一致;列为 NULL 时不匹配 | |
| 534 | + * 4. 达梦、openGauss、神通等其它数据库保持原有片段 | |
| 464 | 535 | * |
| 465 | 536 | * @param column JSON 数组列名,元素为数值 ID |
| 466 | 537 | * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数 |
| 467 | 538 | * @return 包含 {0} 占位符的 SQL 条件片段 |
| 468 | 539 | */ |
| 469 | 540 | public static String jsonArrayContains(String column, boolean wrapArray) { |
| 470 | - return jsonArrayContains(getDialect(), column, wrapArray); | |
| 541 | + return jsonArrayContains(getJsonDialect(), column, wrapArray); | |
| 471 | 542 | } |
| 472 | 543 | |
| 473 | 544 | /** |
| 474 | - * 按指定方言生成 JSON 数组包含数值 ID 的条件;仅 PostgreSQL 输出新片段。 | |
| 545 | + * 按指定方言生成 JSON 数组包含数值 ID 的条件;原有路径保持 JSON_CONTAINS 片段,{0} 在片段中只出现一次。 | |
| 475 | 546 | * |
| 476 | - * @param dialect 已识别的数据库方言 | |
| 547 | + * @param dialect 已识别的 JSON 方言 | |
| 477 | 548 | * @param column 代码中固定的 JSON 数组列名 |
| 478 | 549 | * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数 |
| 479 | 550 | * @return 包含 {0} 占位符的 SQL 条件片段 |
| 480 | 551 | */ |
| 481 | 552 | static String jsonArrayContains(Dialect dialect, String column, boolean wrapArray) { |
| 482 | 553 | checkColumn(column); |
| 483 | - if (dialect == Dialect.POSTGRESQL) { | |
| 484 | - return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))"; | |
| 554 | + switch (dialect) { | |
| 555 | + case POSTGRESQL: | |
| 556 | + return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))"; | |
| 557 | + case ORACLE: | |
| 558 | + return oracleJsonLongArrayContains(column); | |
| 559 | + case SQLSERVER: | |
| 560 | + return sqlServerJsonLongArrayContains(column); | |
| 561 | + case H2: | |
| 562 | + return h2JsonLongArrayContains(column); | |
| 563 | + default: | |
| 564 | + return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})"; | |
| 485 | 565 | } |
| 486 | - return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})"; | |
| 566 | + } | |
| 567 | + | |
| 568 | + /** | |
| 569 | + * Oracle:先用 REGEXP_LIKE 校验整个值为平面 Long / null 数组,再去掉空白、将首尾括号换为逗号,按 ,元素, 精确匹配。 | |
| 570 | + * 数值按 TM9 格式与固定小数点字符转为文本,不受 NLS 影响;参数为 null 时匹配 null 元素,避免 Oracle 空串即 NULL 的拼接陷阱 | |
| 571 | + * | |
| 572 | + * @param column 代码中固定的 JSON 数组列名 | |
| 573 | + * @return 包含 {0} 占位符的 SQL 条件片段 | |
| 574 | + */ | |
| 575 | + private static String oracleJsonLongArrayContains(String column) { | |
| 576 | + // 注意:不能使用 TRANSLATE,CLOB 列超过 4000 字节时会报 ORA-22835;REPLACE 对 CLOB 返回 CLOB | |
| 577 | + String text = "REPLACE(REPLACE(REPLACE(" + column + ", CHR(9), ' '), CHR(10), ' '), CHR(13), ' ')"; | |
| 578 | + return "(REGEXP_LIKE(" + text + ", '" + ORACLE_LONG_ARRAY_PATTERN + "')" | |
| 579 | + + " AND NOT REGEXP_LIKE(" + column + ", '" + LONG_OVERFLOW_PATTERN + "')" | |
| 580 | + + " AND INSTR(REPLACE(REPLACE(REPLACE(" + text + ", ' ', ''), '[', ','), ']', ',')," | |
| 581 | + + " ',' || COALESCE(TO_CHAR(CAST({0} AS NUMBER(19, 0)), 'TM9', 'NLS_NUMERIC_CHARACTERS = ''.,'''), 'null')" | |
| 582 | + + " || ',') > 0)"; | |
| 583 | + } | |
| 584 | + | |
| 585 | + /** | |
| 586 | + * H2:规则同 Oracle,整体格式使用 H2 专用的 {@link #H2_LONG_ARRAY_PATTERN},按 LOCATE 匹配 ,元素, | |
| 587 | + * | |
| 588 | + * @param column 代码中固定的 JSON 数组列名 | |
| 589 | + * @return 包含 {0} 占位符的 SQL 条件片段 | |
| 590 | + */ | |
| 591 | + private static String h2JsonLongArrayContains(String column) { | |
| 592 | + String text = "REPLACE(REPLACE(REPLACE(" + column + ", CHR(9), ' '), CHR(10), ' '), CHR(13), ' ')"; | |
| 593 | + return "(REGEXP_LIKE(" + text + ", '" + H2_LONG_ARRAY_PATTERN + "')" | |
| 594 | + + " AND NOT REGEXP_LIKE(" + column + ", '" + LONG_OVERFLOW_PATTERN + "')" | |
| 595 | + + " AND LOCATE(',' || COALESCE(CAST(CAST({0} AS BIGINT) AS VARCHAR), 'null') || ','," | |
| 596 | + + " REPLACE(REPLACE(REPLACE(" + text + ", ' ', ''), '[', ','), ']', ',')) > 0)"; | |
| 597 | + } | |
| 598 | + | |
| 599 | + /** | |
| 600 | + * SQL Server 2012+:不使用 OPENJSON(要求 2016+ 且兼容级别 130+),用 XML 校验元素 | |
| 601 | + * | |
| 602 | + * 1. TAB、LF、CR 替换为空格后修剪整个值,要求首尾为 [ ];括号内只允许数字、-、逗号、空格及 null 的小写字母(BIN2 比较), | |
| 603 | + * 此时不含 XML 元字符,按逗号拆成 <r><i>...</i></r>,其它值拆成空的 <r/> | |
| 604 | + * 2. 用 .query('.') 物化一份 XML 副本,再对整个文档执行一次 exist 校验(见 {@link #SQL_SERVER_LONG_ARRAY_XQUERY}): | |
| 605 | + * 直接对内联 CAST 的 XML 执行时可能重复解析,本次验证中 1400 个元素的数组会明显变慢 | |
| 606 | + * 3. 校验通过后,去掉空格、首尾补逗号,按 ,元素, 用 CHARINDEX 匹配;参数在 SQL 内转为 bigint 再转文本, | |
| 607 | + * 兼容 String、Long 参数;为 null 时匹配 null 元素 | |
| 608 | + * | |
| 609 | + * 注意:最低按 SQL Server 2012 的语法编写,实际只在 SQL Server 2017(兼容级别 110)上验收。 | |
| 610 | + * 表别名使用 yd_j 前缀,避免遮蔽外层查询的别名 | |
| 611 | + * | |
| 612 | + * @param column 代码中固定的 JSON 数组列名 | |
| 613 | + * @return 包含 {0} 占位符的 SQL 条件片段 | |
| 614 | + */ | |
| 615 | + private static String sqlServerJsonLongArrayContains(String column) { | |
| 616 | + return "EXISTS (SELECT 1" | |
| 617 | + + " FROM (SELECT LTRIM(RTRIM(REPLACE(REPLACE(REPLACE(CAST(" + column + " AS nvarchar(max))," | |
| 618 | + + "CHAR(9),N' '),CHAR(10),N' '),CHAR(13),N' '))) AS doc) yd_jd" | |
| 619 | + + " CROSS APPLY (SELECT CASE WHEN LEFT(yd_jd.doc,1)=N'[' AND RIGHT(yd_jd.doc,1)=N']'" | |
| 620 | + + " THEN SUBSTRING(yd_jd.doc,2,CASE WHEN LEN(yd_jd.doc)>=2 THEN LEN(yd_jd.doc)-2 ELSE 0 END)" | |
| 621 | + + " ELSE NULL END AS body) yd_jb" | |
| 622 | + + " CROSS APPLY (SELECT CAST(CASE WHEN yd_jb.body IS NOT NULL" | |
| 623 | + + " AND PATINDEX(N'%[^0-9,nul -]%',COALESCE(yd_jb.body,N'') COLLATE Latin1_General_100_BIN2)=0" | |
| 624 | + + " AND LTRIM(RTRIM(yd_jb.body))<>N''" | |
| 625 | + + " THEN N'<r><i>'+REPLACE(yd_jb.body,N',',N'</i><i>')+N'</i></r>' ELSE N'<r/>' END AS XML) AS tokens) yd_jx" | |
| 626 | + + " CROSS APPLY (SELECT yd_jx.tokens.query('.') AS tokens) yd_jm" | |
| 627 | + + " WHERE yd_jm.tokens.exist('" + SQL_SERVER_LONG_ARRAY_XQUERY + "') = 1" | |
| 628 | + + " AND CHARINDEX(N','+COALESCE(CONVERT(nvarchar(20),CAST({0} AS BIGINT)),N'null')+N','," | |
| 629 | + + "N','+REPLACE(yd_jb.body,N' ',N'')+N',')>0)"; | |
| 630 | + } | |
| 631 | + | |
| 632 | + /** | |
| 633 | + * 生成与 max 位数相同、数值更大的十进制数字串的正则,例如 max 为 18 时为 (1[9-9]|[2-9][0-9]{1}) | |
| 634 | + * | |
| 635 | + * @param max 不含符号的十进制数字串 | |
| 636 | + * @return 正则片段,不含 {0} 这样会被 Wrapper 当作占位符的文本 | |
| 637 | + */ | |
| 638 | + static String greaterDigitsPattern(String max) { | |
| 639 | + String pattern = null; | |
| 640 | + for (int i = max.length() - 1; i >= 0; i--) { | |
| 641 | + char digit = max.charAt(i); | |
| 642 | + int rest = max.length() - 1 - i; | |
| 643 | + String greater = digit == '9' ? null | |
| 644 | + : "[" + (char) (digit + 1) + "-9]" + (rest > 0 ? "[0-9]{" + rest + "}" : ""); | |
| 645 | + String same = pattern == null ? null : digit + pattern; | |
| 646 | + if (same == null) { | |
| 647 | + pattern = greater; | |
| 648 | + } else { | |
| 649 | + pattern = greater == null ? same : "(" + same + "|" + greater + ")"; | |
| 650 | + } | |
| 651 | + } | |
| 652 | + return pattern; | |
| 487 | 653 | } |
| 488 | 654 | |
| 489 | 655 | // ========== 行数限制 ========== |
| ... | ... | @@ -670,6 +836,57 @@ public class SqlDialectUtils { |
| 670 | 836 | } |
| 671 | 837 | |
| 672 | 838 | /** |
| 839 | + * 取得当前执行的 JSON 方言;连接与缓存规则同 {@link #getDialect()},仅 JSON 片段使用 | |
| 840 | + * | |
| 841 | + * @return JSON 方言 | |
| 842 | + */ | |
| 843 | + static Dialect getJsonDialect() { | |
| 844 | + Connection connection = JdbcUtils.getTransactionalConnection(); | |
| 845 | + if (connection != null) { | |
| 846 | + try { | |
| 847 | + return resolveJsonDialect(connection.getMetaData()); | |
| 848 | + } catch (SQLException e) { | |
| 849 | + throw new IllegalStateException("获取数据库方言失败", e); | |
| 850 | + } | |
| 851 | + } | |
| 852 | + return JSON_DIALECT_CACHE.computeIfAbsent(JdbcUtils.getDataSource(), dataSource -> { | |
| 853 | + try (Connection conn = dataSource.getConnection()) { | |
| 854 | + return resolveJsonDialect(conn.getMetaData()); | |
| 855 | + } catch (SQLException e) { | |
| 856 | + throw new IllegalStateException("获取数据库方言失败", e); | |
| 857 | + } | |
| 858 | + }); | |
| 859 | + } | |
| 860 | + | |
| 861 | + /** | |
| 862 | + * 识别 JSON 方言,其它与 {@link #resolveDialect(DatabaseMetaData)} 一致;日期等其它片段仍保留原有片段: | |
| 863 | + * 1. 瀚高原生驱动(jdbc:highgo:)、人大金仓(产品名 KingbaseES):已验收 jsonb 写法、且不支持 JSON_CONTAINS,按 PostgreSQL 处理 | |
| 864 | + * 2. H2:不支持 JSON_CONTAINS,使用 {@link Dialect#H2} 的严格平面 Long 数组片段 | |
| 865 | + * | |
| 866 | + * @param metaData 数据库元数据 | |
| 867 | + * @return JSON 方言 | |
| 868 | + * @throws SQLException 读取元数据失败 | |
| 869 | + */ | |
| 870 | + static Dialect resolveJsonDialect(DatabaseMetaData metaData) throws SQLException { | |
| 871 | + Dialect dialect = resolveDialect(metaData); | |
| 872 | + if (dialect != Dialect.ORIGINAL) { | |
| 873 | + return dialect; | |
| 874 | + } | |
| 875 | + String url = StrUtil.nullToEmpty(metaData.getURL()).toLowerCase(Locale.ROOT); | |
| 876 | + DbType dbType = DbTypeEnum.find(metaData.getDatabaseProductName()); | |
| 877 | + if (url.startsWith(HIGHGO_URL_PREFIX) && (dbType == null || dbType == DbType.POSTGRE_SQL)) { | |
| 878 | + return Dialect.POSTGRESQL; | |
| 879 | + } | |
| 880 | + if (dbType == DbType.KINGBASE_ES) { | |
| 881 | + return Dialect.POSTGRESQL; | |
| 882 | + } | |
| 883 | + if (dbType == DbType.H2) { | |
| 884 | + return Dialect.H2; | |
| 885 | + } | |
| 886 | + return dialect; | |
| 887 | + } | |
| 888 | + | |
| 889 | + /** | |
| 673 | 890 | * 根据数据库元数据识别方言 |
| 674 | 891 | * |
| 675 | 892 | * 注意:使用 PostgreSQL 官方驱动(jdbc:postgresql:)连接 openGauss、瀚高等兼容 PostgreSQL 的数据库时, | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptorTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.mybatis.core.interceptor; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableLogic; | |
| 6 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 7 | +import com.baomidou.mybatisplus.core.MybatisConfiguration; | |
| 8 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | |
| 9 | +import com.baomidou.mybatisplus.core.injector.methods.SelectList; | |
| 10 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 11 | +import com.baomidou.mybatisplus.core.metadata.TableInfo; | |
| 12 | +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; | |
| 13 | +import lombok.Data; | |
| 14 | +import org.apache.ibatis.builder.MapperBuilderAssistant; | |
| 15 | +import org.apache.ibatis.executor.Executor; | |
| 16 | +import org.apache.ibatis.executor.statement.RoutingStatementHandler; | |
| 17 | +import org.apache.ibatis.mapping.BoundSql; | |
| 18 | +import org.apache.ibatis.mapping.MappedStatement; | |
| 19 | +import org.apache.ibatis.session.RowBounds; | |
| 20 | +import org.junit.jupiter.api.BeforeEach; | |
| 21 | +import org.junit.jupiter.api.Test; | |
| 22 | + | |
| 23 | +import java.sql.Connection; | |
| 24 | +import java.sql.SQLException; | |
| 25 | +import java.util.HashMap; | |
| 26 | +import java.util.Map; | |
| 27 | +import java.util.regex.Pattern; | |
| 28 | + | |
| 29 | +import static org.junit.jupiter.api.Assertions.*; | |
| 30 | +import static org.mockito.Mockito.*; | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * {@link ForUpdateInnerInterceptor} 的单元测试 | |
| 34 | + * | |
| 35 | + * 使用 {@link SelectForUpdateMethod} 实际注入的 MappedStatement 生成 BoundSql,连接为桩对象 | |
| 36 | + * | |
| 37 | + * @author 芋道源码 | |
| 38 | + */ | |
| 39 | +public class ForUpdateInnerInterceptorTest { | |
| 40 | + | |
| 41 | + private static final String SQL_SERVER = "Microsoft SQL Server"; | |
| 42 | + private static final String MYSQL = "MySQL"; | |
| 43 | + private static final String ORACLE = "Oracle"; | |
| 44 | + | |
| 45 | + private final ForUpdateInnerInterceptor interceptor = new ForUpdateInnerInterceptor(); | |
| 46 | + | |
| 47 | + private MybatisConfiguration configuration; | |
| 48 | + | |
| 49 | + @BeforeEach | |
| 50 | + public void setUp() { | |
| 51 | + configuration = new MybatisConfiguration(); | |
| 52 | + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); | |
| 53 | + assistant.setCurrentNamespace(LockMapper.class.getName()); | |
| 54 | + TableInfo tableInfo = TableInfoHelper.initTableInfo(assistant, LockDO.class); | |
| 55 | + new SelectList().inject(assistant, LockMapper.class, LockDO.class, tableInfo); | |
| 56 | + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) { | |
| 57 | + new SelectForUpdateMethod(kind).inject(assistant, LockMapper.class, LockDO.class, tableInfo); | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + // ========== 内置查询 ========== | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + public void testMappedStatement() { | |
| 65 | + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) { | |
| 66 | + MappedStatement ms = getMappedStatement(kind.getMethod()); | |
| 67 | + // 断言:SqlSource 标记、缓存 | |
| 68 | + SelectForUpdateMethod.LockSqlSource sqlSource = assertInstanceOf(SelectForUpdateMethod.LockSqlSource.class, ms.getSqlSource()); | |
| 69 | + assertSame(kind, sqlSource.getKind()); | |
| 70 | + assertTrue(ms.isFlushCacheRequired()); | |
| 71 | + assertFalse(ms.isUseCache()); | |
| 72 | + // 断言:加锁查询返回实体,主键查询返回主键类型 | |
| 73 | + Class<?> resultType = ms.getResultMaps().get(0).getType(); | |
| 74 | + assertSame(kind == SelectForUpdateMethod.Kind.FIRST_IDS ? Long.class : LockDO.class, resultType); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Test | |
| 79 | + public void testBoundSql() { | |
| 80 | + // 准备参数:OR 条件 | |
| 81 | + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>() | |
| 82 | + .eq(LockDO::getName, "a").or().eq(LockDO::getStatus, 1); | |
| 83 | + | |
| 84 | + // 调用、断言:加锁查询,OR 条件整体加括号后,再限制主键与逻辑删除;未设置排序时不排序 | |
| 85 | + String lock = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L).getSql()); | |
| 86 | + assertTrue(Pattern.compile("WHERE \\(\\s*\\(name = \\? OR status = \\?\\)\\s*\\) AND id = \\? AND deleted\\s*=\\s*0 FOR UPDATE$") | |
| 87 | + .matcher(lock).find(), lock); | |
| 88 | + assertFalse(lock.contains("ORDER BY"), lock); | |
| 89 | + // 调用、断言:加锁查询,不传主键时不限制主键 | |
| 90 | + String lockWithoutId = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, null).getSql()); | |
| 91 | + assertFalse(lockWithoutId.contains("id = ?"), lockWithoutId); | |
| 92 | + // 调用、断言:首行加锁查询,未设置排序时不排序 | |
| 93 | + String lockFirst = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK_FIRST, wrapper, null).getSql()); | |
| 94 | + assertTrue(lockFirst.endsWith("AND deleted=0 LIMIT 1 FOR UPDATE"), lockFirst); | |
| 95 | + assertFalse(lockFirst.contains("ORDER BY"), lockFirst); | |
| 96 | + // 调用、断言:主键查询,未设置排序时按主键排序,不加锁 | |
| 97 | + String firstIds = normalize(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql()); | |
| 98 | + assertTrue(firstIds.startsWith("SELECT id FROM infra_lock_demo"), firstIds); | |
| 99 | + assertTrue(firstIds.endsWith("ORDER BY id"), firstIds); | |
| 100 | + // 调用、断言:设置排序时使用 Wrapper 的排序 | |
| 101 | + wrapper.orderByDesc(LockDO::getStatus); | |
| 102 | + assertTrue(normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, null).getSql()) | |
| 103 | + .endsWith("ORDER BY status DESC FOR UPDATE")); | |
| 104 | + assertTrue(normalize(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql()) | |
| 105 | + .endsWith("ORDER BY status DESC")); | |
| 106 | + } | |
| 107 | + | |
| 108 | + // ========== 拦截 ========== | |
| 109 | + | |
| 110 | + @Test | |
| 111 | + public void testBeforePrepare_notLockStatement() { | |
| 112 | + // 准备参数:普通查询,即使 SQL 包含 FOR UPDATE | |
| 113 | + Map<String, Object> params = new HashMap<>(); | |
| 114 | + params.put("ew", new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "for update").last("FOR UPDATE")); | |
| 115 | + MappedStatement ms = getMappedStatement("selectList"); | |
| 116 | + BoundSql boundSql = ms.getBoundSql(params); | |
| 117 | + String sql = boundSql.getSql(); | |
| 118 | + Connection connection = mock(Connection.class); | |
| 119 | + | |
| 120 | + // 调用 | |
| 121 | + interceptor.beforePrepare(newHandler(ms, params, boundSql), connection, null); | |
| 122 | + // 断言:SQL 不变,不访问连接 | |
| 123 | + assertSame(sql, boundSql.getSql()); | |
| 124 | + verifyNoInteractions(connection); | |
| 125 | + } | |
| 126 | + | |
| 127 | + @Test | |
| 128 | + public void testBeforePrepare_sqlServerLock() throws SQLException { | |
| 129 | + // 准备参数 | |
| 130 | + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK, | |
| 131 | + new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), 1L, | |
| 132 | + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_READ_COMMITTED)); | |
| 133 | + String original = getBoundSql(SelectForUpdateMethod.Kind.LOCK, | |
| 134 | + new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), 1L).getSql(); | |
| 135 | + | |
| 136 | + // 断言:删除 FOR UPDATE,表之后增加提示 | |
| 137 | + assertEquals(normalize(original.replace("FROM infra_lock_demo", "FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)") | |
| 138 | + .replace("FOR UPDATE", "")), normalize(boundSql.getSql())); | |
| 139 | + } | |
| 140 | + | |
| 141 | + @Test | |
| 142 | + public void testBeforePrepare_sqlServerLockFirst() throws SQLException { | |
| 143 | + // 准备参数 | |
| 144 | + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK_FIRST, | |
| 145 | + new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), null, | |
| 146 | + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_REPEATABLE_READ)); | |
| 147 | + String original = getBoundSql(SelectForUpdateMethod.Kind.LOCK_FIRST, | |
| 148 | + new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), null).getSql(); | |
| 149 | + | |
| 150 | + // 断言:LIMIT 1 改为 TOP (1),删除 FOR UPDATE,表之后增加提示 | |
| 151 | + assertEquals(normalize(original.replaceFirst("SELECT", "SELECT TOP (1)") | |
| 152 | + .replace("FROM infra_lock_demo", "FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)") | |
| 153 | + .replace("LIMIT 1 FOR UPDATE", "")), normalize(boundSql.getSql())); | |
| 154 | + } | |
| 155 | + | |
| 156 | + @Test | |
| 157 | + public void testBeforePrepare_sqlServerMarkerAfterCopy() throws SQLException { | |
| 158 | + // 准备参数:与 MyBatis Plus Join 一样,复制 MappedStatement 时保留同一个 SqlSource | |
| 159 | + MappedStatement origin = getMappedStatement(SelectForUpdateMethod.Kind.LOCK.getMethod()); | |
| 160 | + MappedStatement ms = new MappedStatement.Builder(configuration, origin.getId(), origin.getSqlSource(), | |
| 161 | + origin.getSqlCommandType()).resultMaps(origin.getResultMaps()).build(); | |
| 162 | + Map<String, Object> params = buildParams(new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a"), null); | |
| 163 | + BoundSql boundSql = ms.getBoundSql(params); | |
| 164 | + | |
| 165 | + // 调用 | |
| 166 | + interceptor.beforePrepare(newHandler(ms, params, boundSql), | |
| 167 | + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_READ_COMMITTED), null); | |
| 168 | + // 断言 | |
| 169 | + assertTrue(boundSql.getSql().contains("FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)"), boundSql.getSql()); | |
| 170 | + assertFalse(boundSql.getSql().contains("FOR UPDATE"), boundSql.getSql()); | |
| 171 | + } | |
| 172 | + | |
| 173 | + @Test | |
| 174 | + public void testBeforePrepare_mysql() throws SQLException { | |
| 175 | + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a"); | |
| 176 | + for (SelectForUpdateMethod.Kind kind : new SelectForUpdateMethod.Kind[]{ | |
| 177 | + SelectForUpdateMethod.Kind.LOCK, SelectForUpdateMethod.Kind.LOCK_FIRST}) { | |
| 178 | + // 准备参数:MySQL 默认的 REPEATABLE READ | |
| 179 | + Connection connection = mockConnection(MYSQL, false, Connection.TRANSACTION_REPEATABLE_READ); | |
| 180 | + | |
| 181 | + // 调用 | |
| 182 | + BoundSql boundSql = beforePrepare(kind, wrapper, null, connection); | |
| 183 | + // 断言:SQL 不变,不校验隔离级别 | |
| 184 | + assertEquals(getBoundSql(kind, wrapper, null).getSql(), boundSql.getSql()); | |
| 185 | + verify(connection, never()).getTransactionIsolation(); | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + @Test | |
| 190 | + public void testBeforePrepare_autoCommit() throws SQLException { | |
| 191 | + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a"); | |
| 192 | + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) { | |
| 193 | + // 调用,并断言异常 | |
| 194 | + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> beforePrepare(kind, wrapper, null, | |
| 195 | + mockConnection(MYSQL, true, Connection.TRANSACTION_READ_COMMITTED))); | |
| 196 | + assertTrue(ex.getMessage().contains("自动提交")); | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + @Test | |
| 201 | + public void testBeforePrepare_firstIds() throws SQLException { | |
| 202 | + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a"); | |
| 203 | + | |
| 204 | + // 调用,并断言异常:非 READ COMMITTED | |
| 205 | + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> beforePrepare(SelectForUpdateMethod.Kind.FIRST_IDS, | |
| 206 | + wrapper, null, mockConnection(ORACLE, false, Connection.TRANSACTION_SERIALIZABLE))); | |
| 207 | + assertTrue(ex.getMessage().contains("READ COMMITTED")); | |
| 208 | + // 调用:READ COMMITTED | |
| 209 | + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null, | |
| 210 | + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED)); | |
| 211 | + // 断言:SQL 不变 | |
| 212 | + assertEquals(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql(), boundSql.getSql()); | |
| 213 | + } | |
| 214 | + | |
| 215 | + @Test | |
| 216 | + public void testBeforePrepare_oracle() throws SQLException { | |
| 217 | + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a"); | |
| 218 | + | |
| 219 | + // 调用:加锁查询,SQL 不变 | |
| 220 | + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L, | |
| 221 | + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED)); | |
| 222 | + assertEquals(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L).getSql(), boundSql.getSql()); | |
| 223 | + // 调用,并断言异常:首行加锁查询 | |
| 224 | + assertThrows(IllegalStateException.class, () -> beforePrepare(SelectForUpdateMethod.Kind.LOCK_FIRST, wrapper, null, | |
| 225 | + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED))); | |
| 226 | + } | |
| 227 | + | |
| 228 | + // ========== SQL Server 改写 ========== | |
| 229 | + | |
| 230 | + @Test | |
| 231 | + public void testRewriteForSqlServer() { | |
| 232 | + // 字符串中的 for update 不改写 | |
| 233 | + assertEquals("SELECT id FROM t WITH (UPDLOCK, ROWLOCK) WHERE name = 'for update' ", | |
| 234 | + ForUpdateInnerInterceptor.rewriteForSqlServer("SELECT id FROM t WHERE name = 'for update' FOR UPDATE", false)); | |
| 235 | + // 子查询的表不加提示 | |
| 236 | + assertEquals("SELECT id FROM a WITH (UPDLOCK, ROWLOCK) WHERE (b IN (SELECT id FROM c WHERE site_id = 1)) ORDER BY id ASC ", | |
| 237 | + ForUpdateInnerInterceptor.rewriteForSqlServer( | |
| 238 | + "SELECT id FROM a WHERE (b IN (SELECT id FROM c WHERE site_id = 1)) ORDER BY id ASC FOR UPDATE", false)); | |
| 239 | + // 首行 | |
| 240 | + assertEquals("SELECT TOP (1) id FROM t WITH (UPDLOCK, ROWLOCK) WHERE (a = ?) ORDER BY id ASC ", | |
| 241 | + ForUpdateInnerInterceptor.rewriteForSqlServer("SELECT id FROM t WHERE (a = ?) ORDER BY id ASC LIMIT 1 FOR UPDATE", true)); | |
| 242 | + } | |
| 243 | + | |
| 244 | + @Test | |
| 245 | + public void testRewriteForSqlServer_unsupported() { | |
| 246 | + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( | |
| 247 | + "SELECT a.id FROM a JOIN b ON a.id = b.id FOR UPDATE", false)); | |
| 248 | + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( | |
| 249 | + "SELECT id FROM t FOR UPDATE NOWAIT", false)); | |
| 250 | + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( | |
| 251 | + "SELECT id FROM t", false)); | |
| 252 | + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( | |
| 253 | + "SELECT id FROM t LIMIT 1 FOR UPDATE", false)); | |
| 254 | + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( | |
| 255 | + "SELECT id FROM t LIMIT 2 FOR UPDATE", true)); | |
| 256 | + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( | |
| 257 | + "SELECT DISTINCT id FROM t LIMIT 1 FOR UPDATE", true)); | |
| 258 | + } | |
| 259 | + | |
| 260 | + // ========== 工具方法 ========== | |
| 261 | + | |
| 262 | + private BoundSql beforePrepare(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper<LockDO> wrapper, Object id, | |
| 263 | + Connection connection) { | |
| 264 | + MappedStatement ms = getMappedStatement(kind.getMethod()); | |
| 265 | + Map<String, Object> params = buildParams(wrapper, id); | |
| 266 | + BoundSql boundSql = ms.getBoundSql(params); | |
| 267 | + interceptor.beforePrepare(newHandler(ms, params, boundSql), connection, null); | |
| 268 | + return boundSql; | |
| 269 | + } | |
| 270 | + | |
| 271 | + private BoundSql getBoundSql(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper<LockDO> wrapper, Object id) { | |
| 272 | + return getMappedStatement(kind.getMethod()).getBoundSql(buildParams(wrapper, id)); | |
| 273 | + } | |
| 274 | + | |
| 275 | + private static Map<String, Object> buildParams(LambdaQueryWrapper<LockDO> wrapper, Object id) { | |
| 276 | + Map<String, Object> params = new HashMap<>(); | |
| 277 | + params.put("ew", wrapper); | |
| 278 | + params.put(SelectForUpdateMethod.PARAM_ID, id); | |
| 279 | + return params; | |
| 280 | + } | |
| 281 | + | |
| 282 | + private MappedStatement getMappedStatement(String method) { | |
| 283 | + return configuration.getMappedStatement(LockMapper.class.getName() + "." + method); | |
| 284 | + } | |
| 285 | + | |
| 286 | + private static RoutingStatementHandler newHandler(MappedStatement ms, Object params, BoundSql boundSql) { | |
| 287 | + return new RoutingStatementHandler(mock(Executor.class), ms, params, RowBounds.DEFAULT, null, boundSql); | |
| 288 | + } | |
| 289 | + | |
| 290 | + private static Connection mockConnection(String productName, boolean autoCommit, int isolation) throws SQLException { | |
| 291 | + Connection connection = mock(Connection.class, RETURNS_DEEP_STUBS); | |
| 292 | + when(connection.getMetaData().getDatabaseProductName()).thenReturn(productName); | |
| 293 | + when(connection.getAutoCommit()).thenReturn(autoCommit); | |
| 294 | + when(connection.getTransactionIsolation()).thenReturn(isolation); | |
| 295 | + return connection; | |
| 296 | + } | |
| 297 | + | |
| 298 | + private static String normalize(String sql) { | |
| 299 | + return sql.replaceAll("\\s+", " ").trim(); | |
| 300 | + } | |
| 301 | + | |
| 302 | + @Data | |
| 303 | + @TableName("infra_lock_demo") | |
| 304 | + public static class LockDO { | |
| 305 | + | |
| 306 | + @TableId | |
| 307 | + private Long id; | |
| 308 | + private String name; | |
| 309 | + private Integer status; | |
| 310 | + @TableLogic | |
| 311 | + private Boolean deleted; | |
| 312 | + | |
| 313 | + } | |
| 314 | + | |
| 315 | + public interface LockMapper extends BaseMapper<LockDO> { | |
| 316 | + } | |
| 317 | + | |
| 318 | +} | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java
| ... | ... | @@ -35,6 +35,7 @@ import java.util.LinkedHashMap; |
| 35 | 35 | import java.util.List; |
| 36 | 36 | import java.util.Map; |
| 37 | 37 | import java.util.function.BiFunction; |
| 38 | +import java.util.regex.Pattern; | |
| 38 | 39 | |
| 39 | 40 | import static org.junit.jupiter.api.Assertions.*; |
| 40 | 41 | |
| ... | ... | @@ -147,9 +148,19 @@ public class SqlDialectUtilsTest { |
| 147 | 148 | assertEquals("COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(contract.order_date AS TIMESTAMP) - " |
| 148 | 149 | + "CAST(customer.create_time AS TIMESTAMP))) AS NUMERIC)), 1), 0)", |
| 149 | 150 | SqlDialectUtils.customerDealCycle(Dialect.POSTGRESQL, "customer.create_time", "contract.order_date")); |
| 150 | - // Oracle、SQL Server:完整天数表达式依赖实际列类型,尚未验收,保持原有片段 | |
| 151 | - assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.ORACLE, "customer.create_time", "contract.order_date")); | |
| 152 | - assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.SQLSERVER, "customer.create_time", "contract.order_date")); | |
| 151 | + // Oracle:升格为 TIMESTAMP(9),DATE 与 TIMESTAMP(9) 均不丢失精度,INTERVAL 的 DAY 部分即完整天数 | |
| 152 | + assertEquals("COALESCE(TRUNC(AVG(EXTRACT(DAY FROM (CAST(contract.order_date AS TIMESTAMP(9)) - " | |
| 153 | + + "CAST(customer.create_time AS TIMESTAMP(9))))), 1), 0)", | |
| 154 | + SqlDialectUtils.customerDealCycle(Dialect.ORACLE, "customer.create_time", "contract.order_date")); | |
| 155 | + // SQL Server:DATEDIFF(DAY) 按时刻向零修正,DECIMAL 平均,ROUND(..., 1, 1) 截断 | |
| 156 | + String beginTime = "CAST(CAST(customer.create_time AS datetime2(7)) AS time(7))"; | |
| 157 | + String endTime = "CAST(CAST(contract.order_date AS datetime2(7)) AS time(7))"; | |
| 158 | + String days = "DATEDIFF(DAY, customer.create_time, contract.order_date)"; | |
| 159 | + assertEquals("COALESCE(ROUND(AVG(CAST((CASE WHEN contract.order_date >= customer.create_time THEN " | |
| 160 | + + days + " - CASE WHEN " + endTime + " < " + beginTime + " THEN 1 ELSE 0 END ELSE " | |
| 161 | + + days + " + CASE WHEN " + endTime + " > " + beginTime + " THEN 1 ELSE 0 END END)" | |
| 162 | + + " AS DECIMAL(19, 4))), 1, 1), 0)", | |
| 163 | + SqlDialectUtils.customerDealCycle(Dialect.SQLSERVER, "customer.create_time", "contract.order_date")); | |
| 153 | 164 | } |
| 154 | 165 | |
| 155 | 166 | @Test |
| ... | ... | @@ -332,18 +343,210 @@ public class SqlDialectUtilsTest { |
| 332 | 343 | |
| 333 | 344 | @Test |
| 334 | 345 | public void testJsonArrayContains() { |
| 335 | - // 原有片段、Oracle、SQL Server:保持原有 JSON_CONTAINS | |
| 336 | - for (Dialect dialect : List.of(Dialect.ORIGINAL, Dialect.ORACLE, Dialect.SQLSERVER)) { | |
| 337 | - assertEquals("JSON_CONTAINS(booking_user_ids, {0})", | |
| 338 | - SqlDialectUtils.jsonArrayContains(dialect, "booking_user_ids", false), dialect.name()); | |
| 339 | - assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))", | |
| 340 | - SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true), dialect.name()); | |
| 341 | - } | |
| 346 | + // 原有片段:保持原有 JSON_CONTAINS | |
| 347 | + assertEquals("JSON_CONTAINS(booking_user_ids, {0})", | |
| 348 | + SqlDialectUtils.jsonArrayContains(Dialect.ORIGINAL, "booking_user_ids", false)); | |
| 349 | + assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))", | |
| 350 | + SqlDialectUtils.jsonArrayContains(Dialect.ORIGINAL, "t.label_ids", true)); | |
| 342 | 351 | // PostgreSQL:两种原有片段统一为 jsonb 包含,参数按 BIGINT 匹配数字元素 |
| 343 | 352 | assertEquals("CAST(booking_user_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))", |
| 344 | 353 | SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "booking_user_ids", false)); |
| 345 | 354 | assertEquals("CAST(t.label_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))", |
| 346 | 355 | SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "t.label_ids", true)); |
| 356 | + // Oracle、SQL Server、H2:两种原有片段相同,{0} 只出现一次,不使用 JSON 函数 | |
| 357 | + for (Dialect dialect : List.of(Dialect.ORACLE, Dialect.SQLSERVER, Dialect.H2)) { | |
| 358 | + String condition = SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true); | |
| 359 | + assertEquals(condition, SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", false), dialect.name()); | |
| 360 | + assertEquals(1, condition.split("\\{0}", -1).length - 1, condition); | |
| 361 | + assertFalse(condition.toUpperCase().contains("JSON"), condition); | |
| 362 | + } | |
| 363 | + String oracle = SqlDialectUtils.jsonArrayContains(Dialect.ORACLE, "t.label_ids", true); | |
| 364 | + // CLOB 超过 4000 字节时 TRANSLATE 报 ORA-22835,空白只能用 REPLACE 替换 | |
| 365 | + assertTrue(oracle.contains("REGEXP_LIKE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), "), | |
| 366 | + oracle); | |
| 367 | + assertFalse(oracle.toUpperCase().contains("TRANSLATE"), oracle); | |
| 368 | + assertTrue(oracle.contains("'" + SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN + "'"), oracle); | |
| 369 | + assertFalse(oracle.contains(SqlDialectUtils.H2_LONG_ARRAY_PATTERN), oracle); | |
| 370 | + assertTrue(oracle.contains("NOT REGEXP_LIKE(t.label_ids, '" + SqlDialectUtils.LONG_OVERFLOW_PATTERN + "')"), oracle); | |
| 371 | + assertTrue(oracle.contains("COALESCE(TO_CHAR(CAST({0} AS NUMBER(19, 0)), 'TM9', " | |
| 372 | + + "'NLS_NUMERIC_CHARACTERS = ''.,'''), 'null')"), oracle); | |
| 373 | + // SQL Server:与原生验收的 SQL 一致;XML 用 .query('.') 物化后整体 exist 校验,再按逗号边界 CHARINDEX 匹配 | |
| 374 | + String sqlServer = SqlDialectUtils.jsonArrayContains(Dialect.SQLSERVER, "t.label_ids", true); | |
| 375 | + assertEquals("EXISTS (SELECT 1" | |
| 376 | + + " FROM (SELECT LTRIM(RTRIM(REPLACE(REPLACE(REPLACE(CAST(t.label_ids AS nvarchar(max)),CHAR(9),N' '),CHAR(10),N' '),CHAR(13),N' '))) AS doc) yd_jd" | |
| 377 | + + " CROSS APPLY (SELECT CASE WHEN LEFT(yd_jd.doc,1)=N'[' AND RIGHT(yd_jd.doc,1)=N']'" | |
| 378 | + + " THEN SUBSTRING(yd_jd.doc,2,CASE WHEN LEN(yd_jd.doc)>=2" | |
| 379 | + + " THEN LEN(yd_jd.doc)-2 ELSE 0 END) ELSE NULL END AS body) yd_jb" | |
| 380 | + + " CROSS APPLY (SELECT CAST(CASE WHEN yd_jb.body IS NOT NULL" | |
| 381 | + + " AND PATINDEX(N'%[^0-9,nul -]%',COALESCE(yd_jb.body,N'') COLLATE Latin1_General_100_BIN2)=0" | |
| 382 | + + " AND LTRIM(RTRIM(yd_jb.body))<>N''" | |
| 383 | + + " THEN N'<r><i>'+REPLACE(yd_jb.body,N',',N'</i><i>')+N'</i></r>' ELSE N'<r/>' END AS XML) AS tokens) yd_jx" | |
| 384 | + + " CROSS APPLY (SELECT yd_jx.tokens.query('.') AS tokens) yd_jm" | |
| 385 | + + " WHERE yd_jm.tokens.exist('/r[i and not(i[string-length(string(xs:token(string(.)))) = 0" | |
| 386 | + + " or (string(xs:token(string(.))) != \"null\"" | |
| 387 | + + " and string(xs:token(string(.))) != string((string(xs:token(string(.))) cast as xs:long?)))])]') = 1" | |
| 388 | + + " AND CHARINDEX(N','+COALESCE(CONVERT(nvarchar(20),CAST({0} AS BIGINT)),N'null')+N',',N','+REPLACE(yd_jb.body,N' ',N'')+N',')>0)", | |
| 389 | + sqlServer); | |
| 390 | + // SQL Server 2012~2016 不支持 OPENJSON,任何分支都不能出现;不逐个遍历节点,XQuery 中不嵌入业务列 | |
| 391 | + assertFalse(sqlServer.toUpperCase().contains("OPENJSON"), sqlServer); | |
| 392 | + assertFalse(sqlServer.contains(".nodes("), sqlServer); | |
| 393 | + assertFalse(sqlServer.contains("sql:column"), sqlServer); | |
| 394 | + // H2:与原生验收的 SQL 一致,使用 H2 专用的正则(Java 正则的占有量词),不使用 Oracle 的正则 | |
| 395 | + String h2 = SqlDialectUtils.jsonArrayContains(Dialect.H2, "t.label_ids", true); | |
| 396 | + assertEquals("^ *+\\[ *+(?:(?:0|-?[1-9][0-9]{0,18}|null) *+(?:, *+(?:0|-?[1-9][0-9]{0,18}|null) *+)*+)?\\] *+$", | |
| 397 | + SqlDialectUtils.H2_LONG_ARRAY_PATTERN); | |
| 398 | + assertEquals("(REGEXP_LIKE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), '" | |
| 399 | + + SqlDialectUtils.H2_LONG_ARRAY_PATTERN + "')" | |
| 400 | + + " AND NOT REGEXP_LIKE(t.label_ids, '" + SqlDialectUtils.LONG_OVERFLOW_PATTERN + "')" | |
| 401 | + + " AND LOCATE(',' || COALESCE(CAST(CAST({0} AS BIGINT) AS VARCHAR), 'null') || ','," | |
| 402 | + + " REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' ')," | |
| 403 | + + " ' ', ''), '[', ','), ']', ',')) > 0)", | |
| 404 | + h2); | |
| 405 | + assertFalse(h2.contains(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN), h2); | |
| 406 | + } | |
| 407 | + | |
| 408 | + /** | |
| 409 | + * Oracle 片段的匹配语义:按 REGEXP_LIKE、REPLACE、INSTR 的组合在 Java 中复现,校验严格的平面 Long / null 数组契约 | |
| 410 | + */ | |
| 411 | + @Test | |
| 412 | + public void testJsonArrayContains_oracleLongArraySemantics() { | |
| 413 | + // 普通匹配:相邻 ID、负数、大 ID、Long 边界、空白 | |
| 414 | + assertTrue(oracleJsonContains("[1,11]", 1L)); | |
| 415 | + assertTrue(oracleJsonContains("[1,11]", 11L)); | |
| 416 | + assertFalse(oracleJsonContains("[11,21]", 1L)); | |
| 417 | + assertTrue(oracleJsonContains("[2115311103682412546,2115311103682412547]", 2115311103682412546L)); | |
| 418 | + assertFalse(oracleJsonContains("[2115311103682412546]", 211531110368241254L)); | |
| 419 | + assertTrue(oracleJsonContains("[-1, 1]", -1L)); | |
| 420 | + assertFalse(oracleJsonContains("[-1]", 1L)); | |
| 421 | + assertTrue(oracleJsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MIN_VALUE)); | |
| 422 | + assertTrue(oracleJsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MAX_VALUE)); | |
| 423 | + assertTrue(oracleJsonContains(" [ 1 ,\t2 ,\r\n 3 ] ", 2L)); | |
| 424 | + assertTrue(oracleJsonContains("[0]", 0L)); | |
| 425 | + // null 元素:参数为 null 时匹配 null 元素 | |
| 426 | + assertTrue(oracleJsonContains("[null]", null)); | |
| 427 | + assertTrue(oracleJsonContains("[1, null]", null)); | |
| 428 | + assertTrue(oracleJsonContains("[1, null]", 1L)); | |
| 429 | + assertFalse(oracleJsonContains("[1]", null)); | |
| 430 | + assertFalse(oracleJsonContains("[NULL]", null)); | |
| 431 | + // 非数组、空数组、列为 NULL | |
| 432 | + assertFalse(oracleJsonContains(null, 1L)); | |
| 433 | + assertFalse(oracleJsonContains(null, null)); | |
| 434 | + assertFalse(oracleJsonContains("null", null)); | |
| 435 | + assertFalse(oracleJsonContains("[]", 1L)); | |
| 436 | + assertFalse(oracleJsonContains("[ ]", null)); | |
| 437 | + assertFalse(oracleJsonContains("1", 1L)); | |
| 438 | + // 字符串、嵌套、对象、小数、指数、前导零、空元素、缺分隔符:整体不匹配 | |
| 439 | + assertFalse(oracleJsonContains("[\"1\"]", 1L)); | |
| 440 | + assertFalse(oracleJsonContains("[\"x,1,y\"]", 1L)); | |
| 441 | + assertFalse(oracleJsonContains("[1,\"x\"]", 1L)); | |
| 442 | + assertFalse(oracleJsonContains("[[1]]", 1L)); | |
| 443 | + assertFalse(oracleJsonContains("[1,[2]]", 1L)); | |
| 444 | + assertFalse(oracleJsonContains("[{\"a\":1}]", 1L)); | |
| 445 | + assertFalse(oracleJsonContains("[1.0]", 1L)); | |
| 446 | + assertFalse(oracleJsonContains("[1e3]", 1000L)); | |
| 447 | + assertFalse(oracleJsonContains("[01]", 1L)); | |
| 448 | + assertFalse(oracleJsonContains("[-0]", 0L)); | |
| 449 | + assertFalse(oracleJsonContains("[1,,2]", 1L)); | |
| 450 | + assertFalse(oracleJsonContains("[,1]", 1L)); | |
| 451 | + assertFalse(oracleJsonContains("[1,]", 1L)); | |
| 452 | + assertFalse(oracleJsonContains("[1 2]", 12L)); | |
| 453 | + assertFalse(oracleJsonContains("[1 2]", 1L)); | |
| 454 | + assertFalse(oracleJsonContains("[1]]", 1L)); | |
| 455 | + // 超出 Long 范围:整个数组不匹配 | |
| 456 | + assertFalse(oracleJsonContains("[9223372036854775808,1]", 1L)); | |
| 457 | + assertFalse(oracleJsonContains("[-9223372036854775809,1]", 1L)); | |
| 458 | + assertFalse(oracleJsonContains("[9999999999999999999,1]", 1L)); | |
| 459 | + assertFalse(oracleJsonContains("[99999999999999999999,1]", 1L)); | |
| 460 | + assertTrue(oracleJsonContains("[-9223372036854775808,1]", 1L)); | |
| 461 | + assertTrue(oracleJsonContains("[9223372036854775807,1]", 1L)); | |
| 462 | + assertTrue(oracleJsonContains("[1000000000000000000,1]", 1L)); | |
| 463 | + // 长 CLOB 数组由 Oracle 实库验证;Java Pattern 的递归栈不代表 Oracle 的正则执行能力。 | |
| 464 | + // H2 使用 Java 正则,其长数组边界由 testJsonArrayContains_h2LongArraySemantics 验证。 | |
| 465 | + } | |
| 466 | + | |
| 467 | + /** | |
| 468 | + * H2 片段的匹配语义:H2 的 REGEXP_LIKE 使用 Java 正则,H2 专用的正则在长数组上不能栈溢出 | |
| 469 | + * | |
| 470 | + * 注意:仅校验正则与匹配规则,不代替 H2 实库验收 | |
| 471 | + */ | |
| 472 | + @Test | |
| 473 | + public void testJsonArrayContains_h2LongArraySemantics() { | |
| 474 | + // 普通匹配、null 元素、空白 | |
| 475 | + assertTrue(h2JsonContains("[1,11]", 1L)); | |
| 476 | + assertFalse(h2JsonContains("[11,21]", 1L)); | |
| 477 | + assertTrue(h2JsonContains("[2115311103682412546,2115311103682412547]", 2115311103682412547L)); | |
| 478 | + assertTrue(h2JsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MIN_VALUE)); | |
| 479 | + assertTrue(h2JsonContains(" [ 1 ,\t2 ,\r\n 3 ] ", 2L)); | |
| 480 | + assertTrue(h2JsonContains("[1, null]", null)); | |
| 481 | + assertFalse(h2JsonContains("[1]", null)); | |
| 482 | + // 非数组、字符串、嵌套、小数、前导零、空元素、缺分隔符、超出 Long 范围:整体不匹配 | |
| 483 | + assertFalse(h2JsonContains(null, 1L)); | |
| 484 | + assertFalse(h2JsonContains("null", null)); | |
| 485 | + assertFalse(h2JsonContains("[]", 1L)); | |
| 486 | + assertFalse(h2JsonContains("[\"1\"]", 1L)); | |
| 487 | + assertFalse(h2JsonContains("[\"x,1,y\"]", 1L)); | |
| 488 | + assertFalse(h2JsonContains("[[1]]", 1L)); | |
| 489 | + assertFalse(h2JsonContains("[1.0]", 1L)); | |
| 490 | + assertFalse(h2JsonContains("[01]", 1L)); | |
| 491 | + assertFalse(h2JsonContains("[1,,2]", 1L)); | |
| 492 | + assertFalse(h2JsonContains("[1 2]", 1L)); | |
| 493 | + assertFalse(h2JsonContains("[9223372036854775808,1]", 1L)); | |
| 494 | + assertFalse(h2JsonContains("[-9223372036854775809,1]", 1L)); | |
| 495 | + // 1400 个元素的长数组:占有量词不回溯、不递归,不能栈溢出;末尾非法元素整体不匹配 | |
| 496 | + StringBuilder longArray = new StringBuilder("[0"); | |
| 497 | + for (int i = 1; i < 1400; i++) { | |
| 498 | + longArray.append(i % 2 == 0 ? ",\n" : " , ").append(2115311103682412546L + i); | |
| 499 | + } | |
| 500 | + String longJson = longArray.append(']').toString(); | |
| 501 | + assertTrue(h2JsonContains(longJson, 2115311103682412546L + 1399)); | |
| 502 | + assertFalse(h2JsonContains(longJson, 2115311103682412546L + 1400)); | |
| 503 | + assertFalse(h2JsonContains(longJson.replace("]", ",\"x\"]"), 0L)); | |
| 504 | + } | |
| 505 | + | |
| 506 | + @Test | |
| 507 | + public void testGreaterDigitsPattern() { | |
| 508 | + assertEquals("(1[9-9]|[2-9][0-9]{1})", SqlDialectUtils.greaterDigitsPattern("18")); | |
| 509 | + Pattern pattern = Pattern.compile(SqlDialectUtils.greaterDigitsPattern("9223372036854775808")); | |
| 510 | + assertTrue(pattern.matcher("9223372036854775809").matches()); | |
| 511 | + assertTrue(pattern.matcher("9223372036854775810").matches()); | |
| 512 | + assertTrue(pattern.matcher("9300000000000000000").matches()); | |
| 513 | + assertTrue(pattern.matcher("9999999999999999999").matches()); | |
| 514 | + assertFalse(pattern.matcher("9223372036854775808").matches()); | |
| 515 | + assertFalse(pattern.matcher("9223372036854775807").matches()); | |
| 516 | + assertFalse(pattern.matcher("8999999999999999999").matches()); | |
| 517 | + assertFalse(pattern.matcher("1000000000000000000").matches()); | |
| 518 | + // Oracle 正则最长 512 字节;也不能包含会被 Wrapper 当作占位符的 {0} | |
| 519 | + assertTrue(SqlDialectUtils.LONG_OVERFLOW_PATTERN.length() < 512); | |
| 520 | + assertTrue(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN.length() < 512); | |
| 521 | + assertFalse(SqlDialectUtils.LONG_OVERFLOW_PATTERN.contains("{0}")); | |
| 522 | + assertFalse(SqlDialectUtils.H2_LONG_ARRAY_PATTERN.contains("{0}")); | |
| 523 | + } | |
| 524 | + | |
| 525 | + @Test | |
| 526 | + public void testResolveJsonDialect() throws SQLException { | |
| 527 | + // 瀚高原生驱动:仅 JSON 片段使用 PostgreSQL 写法,其它片段仍保持原有 | |
| 528 | + assertEquals(Dialect.POSTGRESQL, resolveJson("HighGo", "jdbc:highgo://127.0.0.1:5866/highgo", 6)); | |
| 529 | + assertEquals(Dialect.POSTGRESQL, resolveJson("PostgreSQL", "jdbc:highgo://127.0.0.1:5866/highgo", 6)); | |
| 530 | + assertEquals(Dialect.ORIGINAL, resolve("PostgreSQL", "jdbc:highgo://127.0.0.1:5866/highgo", 6)); | |
| 531 | + // 人大金仓(产品名 KingbaseES):仅 JSON 片段使用 PostgreSQL 写法,其它片段仍保持原有 | |
| 532 | + assertEquals(Dialect.POSTGRESQL, resolveJson("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8)); | |
| 533 | + assertEquals(Dialect.ORIGINAL, resolve("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8)); | |
| 534 | + // H2:仅 JSON 片段使用严格的平面 Long 数组写法,其它片段仍保持原有 | |
| 535 | + assertEquals(Dialect.H2, resolveJson("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2)); | |
| 536 | + assertEquals(Dialect.ORIGINAL, resolve("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2)); | |
| 537 | + // 其它 PostgreSQL 变种(含产品名为 PostgreSQL 的金仓 URL)、达梦、神通、MySQL:保持原有 JSON_CONTAINS | |
| 538 | + assertEquals(Dialect.ORIGINAL, resolveJson("PostgreSQL", "jdbc:opengauss://127.0.0.1:5432/test", 9)); | |
| 539 | + assertEquals(Dialect.ORIGINAL, resolveJson("openGauss", "jdbc:opengauss://127.0.0.1:5432/test", 5)); | |
| 540 | + assertEquals(Dialect.ORIGINAL, resolveJson("PostgreSQL", "jdbc:kingbase8://127.0.0.1:54321/test", 12)); | |
| 541 | + assertEquals(Dialect.ORIGINAL, resolveJson("DM DBMS", "jdbc:dm://127.0.0.1:5236", 8)); | |
| 542 | + assertEquals(Dialect.ORIGINAL, resolveJson("OSCAR", "jdbc:oscar://127.0.0.1:2003/osrdb", 7)); | |
| 543 | + assertEquals(Dialect.ORIGINAL, resolveJson("MySQL", "jdbc:mysql://127.0.0.1:3306/highgo", 8)); | |
| 544 | + assertEquals(Dialect.ORIGINAL, resolveJson("Microsoft SQL Server 2005", "jdbc:jtds:sqlserver://127.0.0.1:1433", 9)); | |
| 545 | + // 其它与 resolveDialect 一致 | |
| 546 | + assertEquals(Dialect.POSTGRESQL, resolveJson("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17)); | |
| 547 | + assertEquals(Dialect.ORACLE, resolveJson("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21)); | |
| 548 | + assertEquals(Dialect.SQLSERVER, resolveJson("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 11)); | |
| 549 | + assertThrows(IllegalStateException.class, () -> resolveJson("SQLite", "jdbc:sqlite:/tmp/test.db", 3)); | |
| 347 | 550 | } |
| 348 | 551 | |
| 349 | 552 | @Test |
| ... | ... | @@ -387,15 +590,20 @@ public class SqlDialectUtilsTest { |
| 387 | 590 | FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21); |
| 388 | 591 | FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16); |
| 389 | 592 | FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8); |
| 390 | - mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, "mysql", mysql)); | |
| 593 | + FakeDataSource h2 = new FakeDataSource("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2); | |
| 594 | + FakeDataSource kingbase = new FakeDataSource("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8); | |
| 595 | + mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, | |
| 596 | + "mysql", mysql, "h2", h2, "kingbase", kingbase)); | |
| 391 | 597 | SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(), |
| 392 | 598 | "<script>SELECT id FROM pms_work_item ${ew.customSqlSegment}</script>", Map.class); |
| 393 | - // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL | |
| 599 | + // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL、H2、人大金仓 | |
| 394 | 600 | Map<String, Dialect> routes = new LinkedHashMap<>(); |
| 395 | 601 | routes.put("master", Dialect.POSTGRESQL); |
| 396 | 602 | routes.put("oracle", Dialect.ORACLE); |
| 397 | 603 | routes.put("sqlserver", Dialect.SQLSERVER); |
| 398 | 604 | routes.put("mysql", Dialect.ORIGINAL); |
| 605 | + routes.put("h2", Dialect.H2); | |
| 606 | + routes.put("kingbase", Dialect.POSTGRESQL); | |
| 399 | 607 | |
| 400 | 608 | for (Map.Entry<String, Dialect> route : routes.entrySet()) { |
| 401 | 609 | DynamicDataSourceContextHolder.push(route.getKey()); |
| ... | ... | @@ -408,7 +616,7 @@ public class SqlDialectUtilsTest { |
| 408 | 616 | String expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "label_ids", true).replace("{0}", "?"); |
| 409 | 617 | assertTrue(sql.startsWith("SELECT id FROM pms_work_item WHERE"), sql); |
| 410 | 618 | assertTrue(sql.contains(expected + " OR " + expected), sql); |
| 411 | - assertFalse(sql.contains("11"), sql); // 值只通过参数绑定,不拼接到 SQL 中 | |
| 619 | + assertFalse(sql.replace(expected, "").contains("11"), sql); // 值只通过参数绑定,不拼接到 SQL 中 | |
| 412 | 620 | assertEquals(List.of(100L, 1L, 11L), getParameterValues(boundSql, labelWrapper)); |
| 413 | 621 | assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); |
| 414 | 622 | |
| ... | ... | @@ -420,7 +628,7 @@ public class SqlDialectUtilsTest { |
| 420 | 628 | sql = normalize(boundSql.getSql()); |
| 421 | 629 | expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "booking_user_ids", false).replace("{0}", "?"); |
| 422 | 630 | assertTrue(sql.contains(expected), sql); |
| 423 | - assertFalse(sql.contains("1024"), sql); | |
| 631 | + assertFalse(sql.replace(expected, "").contains("1024"), sql); | |
| 424 | 632 | assertEquals(List.of(1, "1024"), getParameterValues(boundSql, bookingWrapper)); |
| 425 | 633 | assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); |
| 426 | 634 | } |
| ... | ... | @@ -816,6 +1024,43 @@ public class SqlDialectUtilsTest { |
| 816 | 1024 | |
| 817 | 1025 | // ========== 工具方法 ========== |
| 818 | 1026 | |
| 1027 | + private static Dialect resolveJson(String productName, String url, int majorVersion) throws SQLException { | |
| 1028 | + return SqlDialectUtils.resolveJsonDialect(new FakeDataSource(productName, url, majorVersion).getMetaData()); | |
| 1029 | + } | |
| 1030 | + | |
| 1031 | + /** | |
| 1032 | + * 在 Java 中复现 Oracle 片段:REPLACE 空白、REGEXP_LIKE 整体格式与溢出、去空白后 INSTR 匹配 ,元素, | |
| 1033 | + * | |
| 1034 | + * @param json 列值,null 表示 SQL NULL | |
| 1035 | + * @param value 绑定参数 | |
| 1036 | + * @return 是否匹配 | |
| 1037 | + */ | |
| 1038 | + private static boolean oracleJsonContains(String json, Long value) { | |
| 1039 | + return jsonContains(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN, json, value); | |
| 1040 | + } | |
| 1041 | + | |
| 1042 | + /** | |
| 1043 | + * 在 Java 中复现 H2 片段,规则同 {@link #oracleJsonContains(String, Long)},整体格式使用 H2 专用的正则 | |
| 1044 | + */ | |
| 1045 | + private static boolean h2JsonContains(String json, Long value) { | |
| 1046 | + return jsonContains(SqlDialectUtils.H2_LONG_ARRAY_PATTERN, json, value); | |
| 1047 | + } | |
| 1048 | + | |
| 1049 | + private static boolean jsonContains(String arrayPattern, String json, Long value) { | |
| 1050 | + if (json == null) { | |
| 1051 | + return false; | |
| 1052 | + } | |
| 1053 | + String text = json.replace('\t', ' ').replace('\n', ' ').replace('\r', ' '); | |
| 1054 | + if (!Pattern.compile(arrayPattern).matcher(text).find()) { | |
| 1055 | + return false; | |
| 1056 | + } | |
| 1057 | + if (Pattern.compile(SqlDialectUtils.LONG_OVERFLOW_PATTERN).matcher(json).find()) { | |
| 1058 | + return false; | |
| 1059 | + } | |
| 1060 | + String elements = text.replace(" ", "").replace('[', ',').replace(']', ','); | |
| 1061 | + return elements.contains("," + (value == null ? "null" : value.toString()) + ","); | |
| 1062 | + } | |
| 1063 | + | |
| 819 | 1064 | private static Dialect resolve(String productName, String url, int majorVersion) throws SQLException { |
| 820 | 1065 | return SqlDialectUtils.resolveDialect(new FakeDataSource(productName, url, majorVersion).getMetaData()); |
| 821 | 1066 | } | ... | ... |
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/RedisKeyConstants.java
| ... | ... | @@ -23,4 +23,14 @@ public interface RedisKeyConstants { |
| 23 | 23 | */ |
| 24 | 24 | String INSURANCE_STANDARD_PROJECT = "hrm:insurance_standard_project"; |
| 25 | 25 | |
| 26 | + /** | |
| 27 | + * 月度社保表变更的分布式锁 | |
| 28 | + * | |
| 29 | + * 全局唯一、不区分租户:首月创建、次月创建、删除互斥;各租户的数据仍按租户过滤 | |
| 30 | + * | |
| 31 | + * KEY 格式:hrm:insurance_month_record_mutation_lock | |
| 32 | + * VALUE 数据格式:Redisson 分布式锁 | |
| 33 | + */ | |
| 34 | + String INSURANCE_MONTH_RECORD_MUTATION_LOCK = "hrm:insurance_month_record_mutation_lock"; | |
| 35 | + | |
| 26 | 36 | } | ... | ... |
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/insurance/HrmInsuranceMonthRecordLockRedisDAO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.hrm.dal.redis.insurance; | |
| 2 | + | |
| 3 | +import jakarta.annotation.Resource; | |
| 4 | +import lombok.extern.slf4j.Slf4j; | |
| 5 | +import org.redisson.api.RLock; | |
| 6 | +import org.redisson.api.RedissonClient; | |
| 7 | +import org.springframework.stereotype.Repository; | |
| 8 | +import org.springframework.transaction.support.TransactionSynchronization; | |
| 9 | +import org.springframework.transaction.support.TransactionSynchronizationManager; | |
| 10 | + | |
| 11 | +import java.util.concurrent.TimeUnit; | |
| 12 | +import java.util.function.Supplier; | |
| 13 | + | |
| 14 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.LOCKED; | |
| 15 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 16 | +import static cn.iocoder.yudao.module.hrm.dal.redis.RedisKeyConstants.INSURANCE_MONTH_RECORD_MUTATION_LOCK; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 月度社保表变更的锁 Redis DAO | |
| 20 | + * | |
| 21 | + * 首月创建、次月创建、删除共用一把锁,避免没有记录可锁定时并发创建出多个首月社保表。 | |
| 22 | + * 不设置租期,由 Redisson 看门狗续期;锁在事务提交或回滚后释放,保证下一个获得锁的请求能读到已提交的数据 | |
| 23 | + * | |
| 24 | + * @author 芋道源码 | |
| 25 | + */ | |
| 26 | +@Repository | |
| 27 | +@Slf4j | |
| 28 | +public class HrmInsuranceMonthRecordLockRedisDAO { | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * 等待获得锁的最长时间,单位:秒 | |
| 32 | + */ | |
| 33 | + static final long LOCK_WAIT_SECONDS = 10; | |
| 34 | + | |
| 35 | + @Resource | |
| 36 | + private RedissonClient redissonClient; | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 获得锁并执行逻辑,锁在当前事务完成(提交或回滚)后释放 | |
| 40 | + * | |
| 41 | + * 注意:需要在事务中调用;等待超时、或等待时线程被中断,抛出 {@code LOCKED} 业务异常 | |
| 42 | + * | |
| 43 | + * @param supplier 加锁执行的逻辑 | |
| 44 | + * @return 执行结果 | |
| 45 | + */ | |
| 46 | + public <V> V lock(Supplier<V> supplier) { | |
| 47 | + // 1. 校验事务:存在实际事务、且事务同步已激活,才能在事务完成后释放锁 | |
| 48 | + if (!TransactionSynchronizationManager.isActualTransactionActive() | |
| 49 | + || !TransactionSynchronizationManager.isSynchronizationActive()) { | |
| 50 | + throw new IllegalStateException("月度社保表变更锁需要在事务中获得"); | |
| 51 | + } | |
| 52 | + | |
| 53 | + // 2. 获得锁 | |
| 54 | + RLock lock = redissonClient.getLock(INSURANCE_MONTH_RECORD_MUTATION_LOCK); | |
| 55 | + boolean locked; | |
| 56 | + try { | |
| 57 | + locked = lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS); | |
| 58 | + } catch (InterruptedException e) { | |
| 59 | + Thread.currentThread().interrupt(); | |
| 60 | + throw exception(LOCKED); | |
| 61 | + } | |
| 62 | + if (!locked) { | |
| 63 | + throw exception(LOCKED); | |
| 64 | + } | |
| 65 | + | |
| 66 | + // 3. 事务完成后释放;注册失败时立即释放 | |
| 67 | + try { | |
| 68 | + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { | |
| 69 | + | |
| 70 | + @Override | |
| 71 | + public void afterCompletion(int status) { | |
| 72 | + unlock(lock); | |
| 73 | + } | |
| 74 | + | |
| 75 | + }); | |
| 76 | + } catch (RuntimeException e) { | |
| 77 | + unlock(lock); | |
| 78 | + throw e; | |
| 79 | + } | |
| 80 | + | |
| 81 | + // 4. 执行逻辑;抛出异常时,由事务回滚后释放锁 | |
| 82 | + return supplier.get(); | |
| 83 | + } | |
| 84 | + | |
| 85 | + /** | |
| 86 | + * 获得锁并执行逻辑,见 {@link #lock(Supplier)} | |
| 87 | + * | |
| 88 | + * @param runnable 加锁执行的逻辑 | |
| 89 | + */ | |
| 90 | + public void lock(Runnable runnable) { | |
| 91 | + lock(() -> { | |
| 92 | + runnable.run(); | |
| 93 | + return null; | |
| 94 | + }); | |
| 95 | + } | |
| 96 | + | |
| 97 | + /** | |
| 98 | + * 释放锁;失败时仅打印日志,不影响事务的结果 | |
| 99 | + * | |
| 100 | + * @param lock 锁 | |
| 101 | + */ | |
| 102 | + private static void unlock(RLock lock) { | |
| 103 | + try { | |
| 104 | + lock.unlock(); | |
| 105 | + } catch (RuntimeException e) { | |
| 106 | + log.error("[unlock][月度社保表变更锁({}) 释放失败]", INSURANCE_MONTH_RECORD_MUTATION_LOCK, e); | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | +} | ... | ... |
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImpl.java
| ... | ... | @@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.hrm.controller.admin.insurance.vo.monthrecord.Hrm |
| 6 | 6 | import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthEmployeeRecordDO; |
| 7 | 7 | import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthRecordDO; |
| 8 | 8 | import cn.iocoder.yudao.module.hrm.dal.mysql.insurance.monthrecord.HrmInsuranceMonthRecordMapper; |
| 9 | +import cn.iocoder.yudao.module.hrm.dal.redis.insurance.HrmInsuranceMonthRecordLockRedisDAO; | |
| 9 | 10 | import cn.iocoder.yudao.module.hrm.enums.insurance.employee.HrmInsuranceEmployeeStatusEnum; |
| 10 | 11 | import cn.iocoder.yudao.module.hrm.enums.insurance.monthrecord.HrmInsuranceMonthStatusEnum; |
| 11 | 12 | import com.mzt.logapi.context.LogRecordContext; |
| ... | ... | @@ -50,24 +51,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco |
| 50 | 51 | @Resource |
| 51 | 52 | private HrmInsuranceMonthEmployeeRecordService monthEmployeeRecordService; |
| 52 | 53 | |
| 54 | + @Resource | |
| 55 | + private HrmInsuranceMonthRecordLockRedisDAO monthRecordLockRedisDAO; | |
| 56 | + | |
| 53 | 57 | @Override |
| 54 | 58 | @Transactional(rollbackFor = Exception.class) |
| 55 | 59 | @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_CREATE_SUB_TYPE, |
| 56 | 60 | bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_CREATE_SUCCESS) |
| 57 | 61 | public Long createFirstMonthRecord(HrmInsuranceMonthRecordCreateReqVO reqVO) { |
| 58 | - // 1. 校验尚未生成社保表 | |
| 59 | - if (monthRecordMapper.selectLastForUpdate() != null) { | |
| 60 | - throw exception(INSURANCE_FIRST_MONTH_RECORD_EXISTS); | |
| 61 | - } | |
| 62 | + // 加锁:尚无社保表时没有可锁定的记录,避免并发创建多个首月社保表;锁在事务完成后释放 | |
| 63 | + return monthRecordLockRedisDAO.lock(() -> { | |
| 64 | + // 1. 校验尚未生成社保表 | |
| 65 | + if (monthRecordMapper.selectLastForUpdate() != null) { | |
| 66 | + throw exception(INSURANCE_FIRST_MONTH_RECORD_EXISTS); | |
| 67 | + } | |
| 62 | 68 | |
| 63 | - // 2.1 创建首月社保表 | |
| 64 | - HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(reqVO); | |
| 65 | - // 2.2 生成员工月记录并更新汇总 | |
| 66 | - computeMonthRecord(monthRecord); | |
| 69 | + // 2.1 创建首月社保表 | |
| 70 | + HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(reqVO); | |
| 71 | + // 2.2 生成员工月记录并更新汇总 | |
| 72 | + computeMonthRecord(monthRecord); | |
| 67 | 73 | |
| 68 | - // 3. 记录操作日志上下文 | |
| 69 | - LogRecordContext.putVariable("monthRecord", monthRecord); | |
| 70 | - return monthRecord.getId(); | |
| 74 | + // 3. 记录操作日志上下文 | |
| 75 | + LogRecordContext.putVariable("monthRecord", monthRecord); | |
| 76 | + return monthRecord.getId(); | |
| 77 | + }); | |
| 71 | 78 | } |
| 72 | 79 | |
| 73 | 80 | @Override |
| ... | ... | @@ -75,27 +82,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco |
| 75 | 82 | @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_CREATE_SUB_TYPE, |
| 76 | 83 | bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_CREATE_SUCCESS) |
| 77 | 84 | public Long createNextMonthRecord() { |
| 78 | - // 1. 校验最近月度社保表 | |
| 79 | - HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLastForUpdate(); | |
| 80 | - if (lastMonthRecord == null) { | |
| 81 | - throw exception(INSURANCE_MONTH_RECORD_NOT_EXISTS); | |
| 82 | - } | |
| 83 | - | |
| 84 | - // 2. 归档最近月度社保表 | |
| 85 | - if (Objects.equals(lastMonthRecord.getStatus(), HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())) { | |
| 86 | - archiveMonthRecord(lastMonthRecord); | |
| 87 | - } | |
| 85 | + // 加锁:与首月创建、删除互斥;锁在事务完成后释放 | |
| 86 | + return monthRecordLockRedisDAO.lock(() -> { | |
| 87 | + // 1. 校验最近月度社保表 | |
| 88 | + HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLastForUpdate(); | |
| 89 | + if (lastMonthRecord == null) { | |
| 90 | + throw exception(INSURANCE_MONTH_RECORD_NOT_EXISTS); | |
| 91 | + } | |
| 88 | 92 | |
| 89 | - // 3.1 创建次月社保表 | |
| 90 | - YearMonth nextMonth = YearMonth.of(lastMonthRecord.getYear(), lastMonthRecord.getMonth()).plusMonths(1); | |
| 91 | - HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(new HrmInsuranceMonthRecordCreateReqVO() | |
| 92 | - .setYear(nextMonth.getYear()).setMonth(nextMonth.getMonthValue())); | |
| 93 | - // 3.2 生成员工月记录并更新汇总 | |
| 94 | - computeMonthRecord(monthRecord); | |
| 93 | + // 2. 归档最近月度社保表 | |
| 94 | + if (Objects.equals(lastMonthRecord.getStatus(), HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())) { | |
| 95 | + archiveMonthRecord(lastMonthRecord); | |
| 96 | + } | |
| 95 | 97 | |
| 96 | - // 4. 记录操作日志上下文 | |
| 97 | - LogRecordContext.putVariable("monthRecord", monthRecord); | |
| 98 | - return monthRecord.getId(); | |
| 98 | + // 3.1 创建次月社保表 | |
| 99 | + YearMonth nextMonth = YearMonth.of(lastMonthRecord.getYear(), lastMonthRecord.getMonth()).plusMonths(1); | |
| 100 | + HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(new HrmInsuranceMonthRecordCreateReqVO() | |
| 101 | + .setYear(nextMonth.getYear()).setMonth(nextMonth.getMonthValue())); | |
| 102 | + // 3.2 生成员工月记录并更新汇总 | |
| 103 | + computeMonthRecord(monthRecord); | |
| 104 | + | |
| 105 | + // 4. 记录操作日志上下文 | |
| 106 | + LogRecordContext.putVariable("monthRecord", monthRecord); | |
| 107 | + return monthRecord.getId(); | |
| 108 | + }); | |
| 99 | 109 | } |
| 100 | 110 | |
| 101 | 111 | @Override |
| ... | ... | @@ -103,27 +113,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco |
| 103 | 113 | @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_DELETE_SUB_TYPE, |
| 104 | 114 | bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_DELETE_SUCCESS) |
| 105 | 115 | public void deleteMonthRecord(Long id) { |
| 106 | - // 1. 校验仅删除最新社保表,并至少保留一张社保表 | |
| 107 | - HrmInsuranceMonthRecordDO monthRecord = validateMonthRecordEditableForUpdate(id); | |
| 108 | - HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLast(); | |
| 109 | - if (lastMonthRecord == null || ObjectUtil.notEqual(lastMonthRecord.getId(), monthRecord.getId())) { | |
| 110 | - throw exception(INSURANCE_MONTH_RECORD_NOT_LATEST); | |
| 111 | - } | |
| 112 | - if (monthRecordMapper.selectCount() <= 1) { | |
| 113 | - throw exception(INSURANCE_MONTH_RECORD_CANNOT_DELETE_ONLY); | |
| 114 | - } | |
| 115 | - | |
| 116 | - // 2.1 删除当前月员工记录和月表 | |
| 117 | - monthEmployeeRecordService.deleteMonthEmployeeRecordListByMonthRecordId(id); | |
| 118 | - monthRecordMapper.deleteById(id); | |
| 119 | - // 2.2 将上一月调整为可编辑状态 | |
| 120 | - HrmInsuranceMonthRecordDO previousMonthRecord = monthRecordMapper.selectLast(); | |
| 121 | - monthRecordMapper.updateById(new HrmInsuranceMonthRecordDO() | |
| 122 | - .setId(previousMonthRecord.getId()) | |
| 123 | - .setStatus(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())); | |
| 116 | + // 加锁:与首月创建、次月创建互斥;锁在事务完成后释放 | |
| 117 | + monthRecordLockRedisDAO.lock(() -> { | |
| 118 | + // 1. 校验仅删除最新社保表,并至少保留一张社保表 | |
| 119 | + HrmInsuranceMonthRecordDO monthRecord = validateMonthRecordEditableForUpdate(id); | |
| 120 | + HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLast(); | |
| 121 | + if (lastMonthRecord == null || ObjectUtil.notEqual(lastMonthRecord.getId(), monthRecord.getId())) { | |
| 122 | + throw exception(INSURANCE_MONTH_RECORD_NOT_LATEST); | |
| 123 | + } | |
| 124 | + if (monthRecordMapper.selectCount() <= 1) { | |
| 125 | + throw exception(INSURANCE_MONTH_RECORD_CANNOT_DELETE_ONLY); | |
| 126 | + } | |
| 124 | 127 | |
| 125 | - // 3. 记录操作日志上下文 | |
| 126 | - LogRecordContext.putVariable("monthRecord", monthRecord); | |
| 128 | + // 2.1 删除当前月员工记录和月表 | |
| 129 | + monthEmployeeRecordService.deleteMonthEmployeeRecordListByMonthRecordId(id); | |
| 130 | + monthRecordMapper.deleteById(id); | |
| 131 | + // 2.2 将上一月调整为可编辑状态 | |
| 132 | + HrmInsuranceMonthRecordDO previousMonthRecord = monthRecordMapper.selectLast(); | |
| 133 | + monthRecordMapper.updateById(new HrmInsuranceMonthRecordDO() | |
| 134 | + .setId(previousMonthRecord.getId()) | |
| 135 | + .setStatus(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())); | |
| 136 | + | |
| 137 | + // 3. 记录操作日志上下文 | |
| 138 | + LogRecordContext.putVariable("monthRecord", monthRecord); | |
| 139 | + }); | |
| 127 | 140 | } |
| 128 | 141 | |
| 129 | 142 | @Override | ... | ... |
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImplTest.java
| ... | ... | @@ -5,16 +5,22 @@ import cn.iocoder.yudao.module.hrm.controller.admin.insurance.vo.monthrecord.Hrm |
| 5 | 5 | import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthEmployeeRecordDO; |
| 6 | 6 | import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthRecordDO; |
| 7 | 7 | import cn.iocoder.yudao.module.hrm.dal.mysql.insurance.monthrecord.HrmInsuranceMonthRecordMapper; |
| 8 | +import cn.iocoder.yudao.module.hrm.dal.redis.insurance.HrmInsuranceMonthRecordLockRedisDAO; | |
| 8 | 9 | import cn.iocoder.yudao.module.hrm.enums.insurance.employee.HrmInsuranceEmployeeStatusEnum; |
| 9 | 10 | import cn.iocoder.yudao.module.hrm.enums.insurance.monthrecord.HrmInsuranceMonthStatusEnum; |
| 10 | 11 | import jakarta.annotation.Resource; |
| 12 | +import org.junit.jupiter.api.BeforeEach; | |
| 11 | 13 | import org.junit.jupiter.api.Test; |
| 14 | +import org.mockito.InOrder; | |
| 12 | 15 | import org.springframework.context.annotation.Import; |
| 13 | 16 | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| 14 | 17 | |
| 15 | 18 | import java.math.BigDecimal; |
| 16 | 19 | import java.util.Collections; |
| 20 | +import java.util.function.Supplier; | |
| 17 | 21 | |
| 22 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.LOCKED; | |
| 23 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 18 | 24 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 19 | 25 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 20 | 26 | import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.INSURANCE_FIRST_MONTH_RECORD_EXISTS; |
| ... | ... | @@ -25,6 +31,10 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; |
| 25 | 31 | import static org.junit.jupiter.api.Assertions.assertNull; |
| 26 | 32 | import static org.mockito.ArgumentMatchers.any; |
| 27 | 33 | import static org.mockito.ArgumentMatchers.anyLong; |
| 34 | +import static org.mockito.Mockito.doAnswer; | |
| 35 | +import static org.mockito.Mockito.doThrow; | |
| 36 | +import static org.mockito.Mockito.inOrder; | |
| 37 | +import static org.mockito.Mockito.never; | |
| 28 | 38 | import static org.mockito.Mockito.verify; |
| 29 | 39 | import static org.mockito.Mockito.when; |
| 30 | 40 | |
| ... | ... | @@ -44,6 +54,19 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest { |
| 44 | 54 | |
| 45 | 55 | @MockitoBean |
| 46 | 56 | private HrmInsuranceMonthEmployeeRecordService monthEmployeeRecordService; |
| 57 | + @MockitoBean | |
| 58 | + private HrmInsuranceMonthRecordLockRedisDAO monthRecordLockRedisDAO; | |
| 59 | + | |
| 60 | + @BeforeEach | |
| 61 | + public void setUp() { | |
| 62 | + // mock 方法:获得锁后执行逻辑 | |
| 63 | + when(monthRecordLockRedisDAO.lock(any(Supplier.class))) | |
| 64 | + .thenAnswer(invocation -> invocation.<Supplier<?>>getArgument(0).get()); | |
| 65 | + doAnswer(invocation -> { | |
| 66 | + invocation.<Runnable>getArgument(0).run(); | |
| 67 | + return null; | |
| 68 | + }).when(monthRecordLockRedisDAO).lock(any(Runnable.class)); | |
| 69 | + } | |
| 47 | 70 | |
| 48 | 71 | @Test |
| 49 | 72 | public void testCreateFirstMonthRecord_success() { |
| ... | ... | @@ -63,7 +86,60 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest { |
| 63 | 86 | assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(), monthRecord.getStatus()); |
| 64 | 87 | assertEquals(0, monthRecord.getInsuredEmployeeCount()); |
| 65 | 88 | assertEquals(0, BigDecimal.ZERO.compareTo(monthRecord.getPersonalInsuranceAmount())); |
| 66 | - verify(monthEmployeeRecordService).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class)); | |
| 89 | + InOrder inOrder = inOrder(monthRecordLockRedisDAO, monthEmployeeRecordService); | |
| 90 | + inOrder.verify(monthRecordLockRedisDAO).lock(any(Supplier.class)); | |
| 91 | + inOrder.verify(monthEmployeeRecordService).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class)); | |
| 92 | + } | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + public void testCreateFirstMonthRecord_locked() { | |
| 96 | + // mock 方法:其它请求持有变更锁 | |
| 97 | + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Supplier.class)); | |
| 98 | + // 准备参数 | |
| 99 | + HrmInsuranceMonthRecordCreateReqVO reqVO = | |
| 100 | + new HrmInsuranceMonthRecordCreateReqVO().setYear(2026).setMonth(7); | |
| 101 | + | |
| 102 | + // 调用,并断言异常 | |
| 103 | + assertServiceException(() -> monthRecordService.createFirstMonthRecord(reqVO), LOCKED); | |
| 104 | + // 断言:未创建社保表 | |
| 105 | + assertEquals(0L, monthRecordMapper.selectCount()); | |
| 106 | + verify(monthEmployeeRecordService, never()).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class)); | |
| 107 | + } | |
| 108 | + | |
| 109 | + @Test | |
| 110 | + public void testCreateNextMonthRecord_locked() { | |
| 111 | + // mock 数据 | |
| 112 | + HrmInsuranceMonthRecordDO currentMonthRecord = randomMonthRecord(2026, 6, | |
| 113 | + HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus()); | |
| 114 | + monthRecordMapper.insert(currentMonthRecord); | |
| 115 | + // mock 方法:其它请求持有变更锁 | |
| 116 | + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Supplier.class)); | |
| 117 | + | |
| 118 | + // 调用,并断言异常 | |
| 119 | + assertServiceException(() -> monthRecordService.createNextMonthRecord(), LOCKED); | |
| 120 | + // 断言:未归档、未创建次月 | |
| 121 | + assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(), | |
| 122 | + monthRecordMapper.selectById(currentMonthRecord.getId()).getStatus()); | |
| 123 | + assertEquals(1L, monthRecordMapper.selectCount()); | |
| 124 | + } | |
| 125 | + | |
| 126 | + @Test | |
| 127 | + public void testDeleteMonthRecord_locked() { | |
| 128 | + // mock 数据 | |
| 129 | + HrmInsuranceMonthRecordDO previousMonthRecord = randomMonthRecord(2026, 6, | |
| 130 | + HrmInsuranceMonthStatusEnum.ARCHIVED.getStatus()); | |
| 131 | + monthRecordMapper.insert(previousMonthRecord); | |
| 132 | + HrmInsuranceMonthRecordDO currentMonthRecord = randomMonthRecord(2026, 7, | |
| 133 | + HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus()); | |
| 134 | + monthRecordMapper.insert(currentMonthRecord); | |
| 135 | + // mock 方法:其它请求持有变更锁 | |
| 136 | + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Runnable.class)); | |
| 137 | + | |
| 138 | + // 调用,并断言异常 | |
| 139 | + assertServiceException(() -> monthRecordService.deleteMonthRecord(currentMonthRecord.getId()), LOCKED); | |
| 140 | + // 断言:未删除 | |
| 141 | + assertNotNull(monthRecordMapper.selectById(currentMonthRecord.getId())); | |
| 142 | + verify(monthEmployeeRecordService, never()).deleteMonthEmployeeRecordListByMonthRecordId(anyLong()); | |
| 67 | 143 | } |
| 68 | 144 | |
| 69 | 145 | @Test |
| ... | ... | @@ -123,6 +199,7 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest { |
| 123 | 199 | HrmInsuranceMonthRecordDO nextMonthRecord = monthRecordMapper.selectById(nextMonthRecordId); |
| 124 | 200 | assertEquals(2027, nextMonthRecord.getYear()); |
| 125 | 201 | assertEquals(1, nextMonthRecord.getMonth()); |
| 202 | + verify(monthRecordLockRedisDAO).lock(any(Supplier.class)); | |
| 126 | 203 | } |
| 127 | 204 | |
| 128 | 205 | @Test |
| ... | ... | @@ -142,7 +219,9 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest { |
| 142 | 219 | assertNull(monthRecordMapper.selectById(currentMonthRecord.getId())); |
| 143 | 220 | assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(), |
| 144 | 221 | monthRecordMapper.selectById(previousMonthRecord.getId()).getStatus()); |
| 145 | - verify(monthEmployeeRecordService) | |
| 222 | + InOrder inOrder = inOrder(monthRecordLockRedisDAO, monthEmployeeRecordService); | |
| 223 | + inOrder.verify(monthRecordLockRedisDAO).lock(any(Runnable.class)); | |
| 224 | + inOrder.verify(monthEmployeeRecordService) | |
| 146 | 225 | .deleteMonthEmployeeRecordListByMonthRecordId(currentMonthRecord.getId()); |
| 147 | 226 | } |
| 148 | 227 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java
| ... | ... | @@ -16,9 +16,7 @@ import org.apache.ibatis.annotations.Mapper; |
| 16 | 16 | public interface ImGroupMapper extends BaseMapperX<ImGroupDO> { |
| 17 | 17 | |
| 18 | 18 | default ImGroupDO selectByIdForUpdate(Long id) { |
| 19 | - return selectOne(new LambdaQueryWrapperX<ImGroupDO>() | |
| 20 | - .eq(ImGroupDO::getId, id) | |
| 21 | - .last("FOR UPDATE")); | |
| 19 | + return selectOneForUpdate(ImGroupDO::getId, id); | |
| 22 | 20 | } |
| 23 | 21 | |
| 24 | 22 | default PageResult<ImGroupDO> selectPage(ImGroupManagerPageReqVO reqVO) { | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java
| ... | ... | @@ -28,8 +28,8 @@ public interface OaFileNodeMapper extends BaseMapperX<OaFileNodeDO> { |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 30 | default List<OaFileNodeDO> selectListByCreatorAndTypeForUpdate(String creator, Integer type) { |
| 31 | - return selectList(new LambdaQueryWrapperX<OaFileNodeDO>().eq(OaFileNodeDO::getCreator, creator) | |
| 32 | - .eq(OaFileNodeDO::getType, type).orderByAsc(OaFileNodeDO::getId).last("FOR UPDATE")); | |
| 31 | + return selectListForUpdate(new LambdaQueryWrapperX<OaFileNodeDO>().eq(OaFileNodeDO::getCreator, creator) | |
| 32 | + .eq(OaFileNodeDO::getType, type).orderByAsc(OaFileNodeDO::getId)); | |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | default List<OaFileNodeDO> selectListByCreatorAndStatus(String creator, Integer status) { | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java
| ... | ... | @@ -32,8 +32,8 @@ public interface OaMailAccountMapper extends BaseMapperX<OaMailAccountDO> { |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | default List<OaMailAccountDO> selectListByCreatorForUpdate(String creator) { |
| 35 | - return selectList(new LambdaQueryWrapperX<OaMailAccountDO>().eq(OaMailAccountDO::getCreator, creator) | |
| 36 | - .orderByAsc(OaMailAccountDO::getId).last("FOR UPDATE")); | |
| 35 | + return selectListForUpdate(new LambdaQueryWrapperX<OaMailAccountDO>().eq(OaMailAccountDO::getCreator, creator) | |
| 36 | + .orderByAsc(OaMailAccountDO::getId)); | |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | default Long selectCountByProviderId(Long providerId) { | ... | ... |
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java
| ... | ... | @@ -250,11 +250,11 @@ public class PayWalletServiceTest extends BaseDbAndRedisUnitTest { |
| 250 | 250 | mockRefundAndWalletTransaction(); |
| 251 | 251 | List<Integer> declaredIsolationLevels = new ArrayList<>(); |
| 252 | 252 | List<Integer> connectionIsolationLevels = new ArrayList<>(); |
| 253 | - when(refundService.getRefundForUpdate(any())).thenAnswer(invocation -> { | |
| 253 | + doAnswer(invocation -> { | |
| 254 | 254 | declaredIsolationLevels.add(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel()); |
| 255 | 255 | connectionIsolationLevels.add(DataSourceUtils.getConnection(dataSource).getTransactionIsolation()); |
| 256 | 256 | return refundMapper.selectOneForUpdate(PayRefundDO::getId, invocation.getArgument(0)); |
| 257 | - }); | |
| 257 | + }).when(refundService).getRefundForUpdate(any()); | |
| 258 | 258 | |
| 259 | 259 | // 调用:不存在调用方事务,新建事务退款到钱包 |
| 260 | 260 | PayWalletTransactionDO transaction = walletService.orderRefund(refund.getNo(), 30, "测试退款"); | ... | ... |
yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java
| ... | ... | @@ -334,7 +334,7 @@ public interface PmsWorkItemMapper extends BaseMapperX<PmsWorkItemDO> { |
| 334 | 334 | queryWrapper.and(wrapper -> wrapper.notIn(PmsWorkItemDO::getIterationId, |
| 335 | 335 | pageReqVO.getExcludedIterationIds()).or().isNull(PmsWorkItemDO::getIterationId)); |
| 336 | 336 | } |
| 337 | - // label_ids 为 JSON 数组列,匹配任一标签;PostgreSQL 使用 jsonb 包含,其它数据库保持 JSON_CONTAINS | |
| 337 | + // label_ids 为 JSON 数组列,匹配任一标签;数据库差异由公共方言工具处理 | |
| 338 | 338 | if (CollUtil.isNotEmpty(pageReqVO.getLabelIds())) { |
| 339 | 339 | String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true); |
| 340 | 340 | queryWrapper.and(labelWrapper -> { | ... | ... |
yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java
| ... | ... | @@ -66,9 +66,7 @@ public interface WmsInventoryMapper extends BaseMapperX<WmsInventoryDO> { |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | default WmsInventoryDO selectByIdForUpdate(Long id) { |
| 69 | - return selectOne(new LambdaQueryWrapperX<WmsInventoryDO>() | |
| 70 | - .eq(WmsInventoryDO::getId, id) | |
| 71 | - .last("FOR UPDATE")); | |
| 69 | + return selectOneForUpdate(WmsInventoryDO::getId, id); | |
| 72 | 70 | } |
| 73 | 71 | |
| 74 | 72 | /** |
| ... | ... | @@ -96,12 +94,7 @@ public interface WmsInventoryMapper extends BaseMapperX<WmsInventoryDO> { |
| 96 | 94 | } |
| 97 | 95 | |
| 98 | 96 | default List<WmsInventoryDO> selectListByIdsForUpdate(Collection<Long> ids) { |
| 99 | - if (CollUtil.isEmpty(ids)) { | |
| 100 | - return Collections.emptyList(); | |
| 101 | - } | |
| 102 | - return selectList(new LambdaQueryWrapperX<WmsInventoryDO>() | |
| 103 | - .in(WmsInventoryDO::getId, ids) | |
| 104 | - .last("FOR UPDATE")); | |
| 97 | + return selectListForUpdate(WmsInventoryDO::getId, ids); | |
| 105 | 98 | } |
| 106 | 99 | |
| 107 | 100 | static void appendDimensionOrder(MPJLambdaWrapperX<WmsInventoryDO> query, String type) { | ... | ... |