diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java
index f1f5396..eae9464 100644
--- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java
+++ b/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;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.mybatis.core.handler.DefaultDBFieldHandler;
import cn.iocoder.yudao.framework.mybatis.core.injector.YudaoSqlInjector;
+import cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor;
import cn.iocoder.yudao.framework.mybatis.core.util.CaseInsensitiveResultMap;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer;
@@ -57,6 +58,8 @@ public class YudaoMybatisAutoConfiguration {
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件
+ // 锁定读插件:需要位于末尾,在租户、数据权限、分页等插件改写 SQL 之后,改写 SQL Server 的行锁
+ mybatisPlusInterceptor.addInnerInterceptor(new ForUpdateInnerInterceptor());
// ↓↓↓ 按需开启,可能会影响到 updateBatch 的地方:例如说文件配置管理 ↓↓↓
// mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 拦截没有指定条件的 update 和 delete 语句
return mybatisPlusInterceptor;
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java
new file mode 100644
index 0000000..454b758
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java
@@ -0,0 +1,168 @@
+package cn.iocoder.yudao.framework.mybatis.core.injector;
+
+import com.baomidou.mybatisplus.core.injector.AbstractMethod;
+import com.baomidou.mybatisplus.core.metadata.TableInfo;
+import com.baomidou.mybatisplus.core.toolkit.sql.SqlScriptUtils;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.apache.ibatis.executor.keygen.KeyGenerator;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.mapping.MappedStatement;
+import org.apache.ibatis.mapping.SqlCommandType;
+import org.apache.ibatis.mapping.SqlSource;
+import org.apache.ibatis.mapping.StatementType;
+
+/**
+ * 锁定读使用的内置查询,见 BaseMapperX 的 selectOneForUpdate、selectListForUpdate、selectFirstOneForUpdate 方法
+ *
+ * 按 {@link Kind} 分为三类,均不在 Mapper 接口上声明,由 BaseMapperX 通过 SqlSession 调用:
+ * 1. {@link Kind#LOCK}:按原条件(可选再加主键)加锁查询,末尾为 FOR UPDATE
+ * 2. {@link Kind#LOCK_FIRST}:按原条件、原排序加锁查询首行,末尾为 LIMIT 1 FOR UPDATE
+ * 3. {@link Kind#FIRST_IDS}:按原条件、原排序(未设置时按主键)查询首行主键,行数限制由分页插件生成,不加锁;
+ * 仅用于 Oracle 的首行锁定(Oracle 的 FOR UPDATE 不能与 ROWNUM、FETCH FIRST 一起使用)
+ *
+ * SQL Server 不支持 FOR UPDATE、LIMIT,由 {@link cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor}
+ * 在所有解析 SQL 的插件之后改写;该拦截器只处理 SqlSource 为 {@link LockSqlSource} 的内置查询
+ *
+ * 1. 条件:Wrapper 的实体条件、普通条件(整体加括号,OR 条件也均受主键限制)、逻辑删除,与 MyBatis Plus 的 selectList 一致;
+ * 不使用 Wrapper 的 last、groupBy、having,由调用方校验
+ * 2. 列与结果:加锁查询沿用 selectList 的查询列(含 Wrapper 的 select)、resultMap;主键查询的结果为主键类型
+ * 3. 缓存:每次执行前清空一级缓存、且不使用二级缓存,避免锁定前后读到缓存的结果
+ *
+ * @author 芋道源码
+ */
+public class SelectForUpdateMethod extends AbstractMethod {
+
+ /**
+ * 加锁查询的主键参数名
+ */
+ public static final String PARAM_ID = "id";
+
+ private final Kind kind;
+
+ public SelectForUpdateMethod(Kind kind) {
+ super(kind.getMethod());
+ this.kind = kind;
+ }
+
+ @Override
+ public MappedStatement injectMappedStatement(Class> mapperClass, Class> modelClass, TableInfo tableInfo) {
+ // 没有主键的表,无法查询首行主键
+ if (kind == Kind.FIRST_IDS && !tableInfo.havePK()) {
+ return null;
+ }
+ String columns = kind == Kind.FIRST_IDS ? tableInfo.getKeyColumn() : sqlSelectColumns(tableInfo, true);
+ String sql = "";
+ SqlSource sqlSource = new LockSqlSource(kind, super.createSqlSource(configuration, sql, modelClass));
+ if (kind == Kind.FIRST_IDS) {
+ return addSelectMappedStatementForOther(mapperClass, methodName, sqlSource, tableInfo.getKeyType());
+ }
+ return addSelectMappedStatementForTable(mapperClass, methodName, sqlSource, tableInfo);
+ }
+
+ /**
+ * 条件:实体条件 + 普通条件(加括号) + [主键] + 逻辑删除
+ *
+ * @param tableInfo 表信息
+ * @return WHERE 脚本
+ */
+ private String sqlWhere(TableInfo tableInfo) {
+ String entityWhere = SqlScriptUtils.convertIf(tableInfo.getAllSqlWhere(true, true, true, WRAPPER_ENTITY_DOT),
+ WRAPPER_ENTITY + " != null", true);
+ String normalWhere = SqlScriptUtils.convertIf(" AND (${" + WRAPPER_DOT + "expression.normal.sqlSegment})",
+ WRAPPER_DOT + "nonEmptyOfNormal", true);
+ String idWhere = kind == Kind.LOCK && tableInfo.havePK() ? NEWLINE + SqlScriptUtils.convertIf(
+ " AND " + tableInfo.getKeyColumn() + " = " + SqlScriptUtils.safeParam(PARAM_ID), PARAM_ID + " != null", true) : "";
+ return SqlScriptUtils.convertWhere(entityWhere + NEWLINE + normalWhere + idWhere
+ + NEWLINE + tableInfo.getLogicDeleteSql(true, true));
+ }
+
+ /**
+ * 排序与锁定:
+ * 1. 加锁查询:Wrapper 的排序(未设置时不排序,避免改变数据库的加锁范围) + FOR UPDATE;首行再加 LIMIT 1
+ * 2. 主键查询:Wrapper 的排序;未设置时按主键排序,保证锁定前后读取的首行一致
+ *
+ * @param tableInfo 表信息
+ * @return ORDER BY 等脚本
+ */
+ private String sqlTail(TableInfo tableInfo) {
+ String orderBy = " ${" + WRAPPER_DOT + "expression.orderBy.sqlSegment}";
+ if (kind == Kind.FIRST_IDS) {
+ return SqlScriptUtils.convertChoose(WRAPPER_DOT + "expression.orderBy.isEmpty()",
+ " ORDER BY " + tableInfo.getKeyColumn(), orderBy);
+ }
+ return SqlScriptUtils.convertIf(orderBy, "!" + WRAPPER_DOT + "expression.orderBy.isEmpty()", true)
+ + NEWLINE + (kind == Kind.LOCK_FIRST ? "LIMIT 1 FOR UPDATE" : "FOR UPDATE");
+ }
+
+ @Override
+ protected MappedStatement addMappedStatement(Class> mapperClass, String id, SqlSource sqlSource,
+ SqlCommandType sqlCommandType, Class> parameterType,
+ String resultMap, Class> resultType, KeyGenerator keyGenerator,
+ String keyProperty, String keyColumn) {
+ String statementName = mapperClass.getName() + DOT + id;
+ if (configuration.hasStatement(statementName, false)) {
+ 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);
+ return null;
+ }
+ // 与父类相比:flushCache 为 true、useCache 为 false
+ return builderAssistant.addMappedStatement(id, sqlSource, StatementType.PREPARED, sqlCommandType,
+ null, null, null, parameterType, resultMap, resultType,
+ null, true, false, false, keyGenerator, keyProperty, keyColumn,
+ configuration.getDatabaseId(), languageDriver, null);
+ }
+
+ /**
+ * 内置查询的类型
+ */
+ @Getter
+ @AllArgsConstructor
+ public enum Kind {
+
+ /**
+ * 加锁查询:按原条件、可选的主键加锁,返回实体
+ */
+ LOCK("_forUpdateLock"),
+ /**
+ * 首行加锁查询:按原条件、原排序加锁第 1 条,返回实体
+ */
+ LOCK_FIRST("_forUpdateLockFirst"),
+ /**
+ * 首行主键查询:按原条件、原排序查询第 1 条的主键,不加锁,返回主键
+ */
+ FIRST_IDS("_forUpdateFirstIds");
+
+ /**
+ * 方法名,即 MappedStatement 编号中 Mapper 之后的部分
+ */
+ private final String method;
+
+ }
+
+ /**
+ * 内置查询的 SqlSource:标记 MappedStatement 为锁定读的内置查询,SQL 由原 SqlSource 生成
+ *
+ * MyBatis Plus Join 等插件复制 MappedStatement 时保留同一个 SqlSource,标记仍然有效
+ */
+ @Getter
+ @AllArgsConstructor
+ public static class LockSqlSource implements SqlSource {
+
+ /**
+ * 内置查询的类型
+ */
+ private final Kind kind;
+ /**
+ * 原 SqlSource
+ */
+ private final SqlSource delegate;
+
+ @Override
+ public BoundSql getBoundSql(Object parameterObject) {
+ return delegate.getBoundSql(parameterObject);
+ }
+
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java
index 2fc1f72..d6e271f 100644
--- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java
+++ b/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;
* 自定义 SQL 注入器
*
* 在 MyBatis Plus Join 的 {@link MPJSqlInjector} 基础上,将 selectMaps 的结果行替换为 {@link CaseInsensitiveResultMap},
- * 解决 PostgreSQL、Oracle 等数据库列标签大小写折叠,导致按固定 key 取不到值的问题
+ * 解决 PostgreSQL、Oracle 等数据库列标签大小写折叠,导致按固定 key 取不到值的问题;
+ * 并增加锁定读使用的内置查询 {@link SelectForUpdateMethod}
*
* @author 芋道源码
*/
@@ -25,6 +26,9 @@ public class YudaoSqlInjector extends MPJSqlInjector {
public List getMethodList(Configuration configuration, Class> mapperClass, TableInfo tableInfo) {
List methods = super.getMethodList(configuration, mapperClass, tableInfo);
methods.replaceAll(method -> method instanceof SelectMaps ? new CaseInsensitiveSelectMaps() : method);
+ for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) {
+ methods.add(new SelectForUpdateMethod(kind));
+ }
return methods;
}
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java
new file mode 100644
index 0000000..b4a4efb
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java
@@ -0,0 +1,211 @@
+package cn.iocoder.yudao.framework.mybatis.core.interceptor;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
+import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum;
+import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod;
+import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
+import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
+import net.sf.jsqlparser.JSQLParserException;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.expression.LongValue;
+import net.sf.jsqlparser.parser.CCJSqlParserConstants;
+import net.sf.jsqlparser.parser.SimpleNode;
+import net.sf.jsqlparser.parser.Token;
+import net.sf.jsqlparser.schema.Table;
+import net.sf.jsqlparser.statement.Statement;
+import net.sf.jsqlparser.statement.select.ForMode;
+import net.sf.jsqlparser.statement.select.Limit;
+import net.sf.jsqlparser.statement.select.PlainSelect;
+import org.apache.ibatis.executor.statement.StatementHandler;
+import org.apache.ibatis.mapping.SqlSource;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+// TODO DONE @AI:能不能不这么搞,而是 mapperx 根据数据库做封装噢?
+// 选择执行路径已在 BaseMapperX 按数据库封装(Oracle 首行锁定走多次查询,其它单条语句);
+// SQL Server 的 WITH (UPDLOCK, ROWLOCK) 无法被 JSqlParser 解析,若在 Mapper 中直接生成,租户、数据权限等插件会解析失败,
+// 因此只能在所有解析 SQL 的插件之后改写。本拦截器只处理 SelectForUpdateMethod 的内置查询,不再扫描其它 SQL。
+/**
+ * 锁定读的拦截器,需要位于拦截器链的末尾:在 prepare 阶段处理,此时租户、数据权限、分页等插件已完成 SQL 改写
+ *
+ * 只处理 SqlSource 为 {@link SelectForUpdateMethod.LockSqlSource} 的内置查询,其它 SQL 不做任何处理:
+ * 1. 在实际执行的连接上校验事务:要求非自动提交;Oracle 首行锁定的主键查询({@link SelectForUpdateMethod.Kind#FIRST_IDS})
+ * 还要求隔离级别为 READ COMMITTED,其它隔离级别下,锁定后的重新读取可能读不到其它事务已提交的记录
+ * 2. SQL Server 不支持 FOR UPDATE、LIMIT:将末尾的 FOR UPDATE 改写为基表的 WITH (UPDLOCK, ROWLOCK),首行的 LIMIT 1 改写为 TOP (1);
+ * 只按 SQL 的 token 修改,字符串中的 for update 不会被改写;仅支持单表,不支持时直接报错,不会退化为不加锁的 SQL
+ * 3. Oracle 不支持 LIMIT 1 FOR UPDATE:首行加锁查询({@link SelectForUpdateMethod.Kind#LOCK_FIRST})直接报错,
+ * 正常情况下 BaseMapperX 已按数据库类型改为多次查询
+ *
+ * @author 芋道源码
+ */
+public class ForUpdateInnerInterceptor implements InnerInterceptor {
+
+ /**
+ * SQL Server 的行锁提示:UPDLOCK 持有更新锁到事务结束,与 FOR UPDATE 一样阻塞其它事务的加锁读与修改;ROWLOCK 尽量使用行锁
+ */
+ static final String SQL_SERVER_LOCK_HINTS = " WITH (UPDLOCK, ROWLOCK)";
+ /**
+ * SQL Server 的首行限制,替代 LIMIT 1
+ */
+ static final String SQL_SERVER_TOP_ONE = " TOP (1)";
+
+ @Override
+ public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
+ PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
+ SqlSource sqlSource = mpSh.mappedStatement().getSqlSource();
+ if (!(sqlSource instanceof SelectForUpdateMethod.LockSqlSource)) {
+ return;
+ }
+ SelectForUpdateMethod.Kind kind = ((SelectForUpdateMethod.LockSqlSource) sqlSource).getKind();
+ // 1. 校验实际连接的事务
+ checkTransaction(connection, kind);
+ // 2. 按实际连接的数据库类型处理:首行主键查询不加锁,无需改写
+ if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) {
+ return;
+ }
+ DbType dbType = getDbType(connection);
+ if (JdbcUtils.isSQLServer(dbType)) {
+ PluginUtils.MPBoundSql mpBoundSql = mpSh.mPBoundSql();
+ mpBoundSql.sql(rewriteForSqlServer(mpBoundSql.sql(), kind == SelectForUpdateMethod.Kind.LOCK_FIRST));
+ return;
+ }
+ if (kind == SelectForUpdateMethod.Kind.LOCK_FIRST && ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) {
+ throw new IllegalStateException("Oracle 不支持 LIMIT 1 FOR UPDATE,首行锁定需要使用多次查询");
+ }
+ }
+
+ /**
+ * 校验连接在事务中;首行主键查询还要求隔离级别为 READ COMMITTED
+ *
+ * @param connection 实际执行的连接
+ * @param kind 内置查询的类型
+ */
+ static void checkTransaction(Connection connection, SelectForUpdateMethod.Kind kind) {
+ try {
+ if (connection.getAutoCommit()) {
+ throw new IllegalStateException("锁定读需要在事务中执行,当前连接为自动提交");
+ }
+ if (kind != SelectForUpdateMethod.Kind.FIRST_IDS) {
+ return;
+ }
+ int isolation = connection.getTransactionIsolation();
+ if (isolation != Connection.TRANSACTION_READ_COMMITTED) {
+ throw new IllegalStateException("首行锁定要求事务隔离级别为 READ COMMITTED,当前为:" + isolation);
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("获取连接的事务状态失败", e);
+ }
+ }
+
+ private static DbType getDbType(Connection connection) {
+ try {
+ return DbTypeEnum.find(connection.getMetaData().getDatabaseProductName());
+ } catch (SQLException e) {
+ throw new IllegalStateException("获取数据库类型失败", e);
+ }
+ }
+
+ /**
+ * 将单表 SELECT 末尾的 FOR UPDATE 改写为表的 WITH (UPDLOCK, ROWLOCK);首行时将 LIMIT 1 改写为 TOP (1)
+ *
+ * @param sql SQL
+ * @param first 是否为首行加锁查询
+ * @return 改写后的 SQL
+ */
+ static String rewriteForSqlServer(String sql, boolean first) {
+ Statement statement;
+ try {
+ statement = SqlTokenUtils.parse(sql);
+ } catch (JSQLParserException e) {
+ throw new IllegalStateException("解析行锁 SQL 失败:" + sql, e);
+ }
+ // 校验:仅支持单表、无修饰的 FOR UPDATE
+ if (!(statement instanceof PlainSelect)) {
+ throw unsupported("仅支持单个 SELECT", sql);
+ }
+ PlainSelect plainSelect = (PlainSelect) statement;
+ if (plainSelect.getForMode() != ForMode.UPDATE || plainSelect.getForUpdateTable() != null
+ || plainSelect.getWait() != null || plainSelect.isNoWait() || plainSelect.isSkipLocked()) {
+ throw unsupported("仅支持 FOR UPDATE,不支持 OF、WAIT、NOWAIT、SKIP LOCKED 等", sql);
+ }
+ if (CollUtil.isNotEmpty(plainSelect.getWithItemsList())) {
+ throw unsupported("不支持 WITH", sql);
+ }
+ if (!(plainSelect.getFromItem() instanceof Table) || CollUtil.isNotEmpty(plainSelect.getJoins())) {
+ throw unsupported("仅支持单表", sql);
+ }
+ Table table = (Table) plainSelect.getFromItem();
+ if (table.getSqlServerHints() != null || table.getIndexHint() != null || table.getSampleClause() != null
+ || table.getPivot() != null || table.getUnPivot() != null) {
+ throw unsupported("表已有提示等修饰", sql);
+ }
+ if (plainSelect.getTop() != null || plainSelect.getOffset() != null || plainSelect.getFetch() != null) {
+ throw unsupported("不支持 TOP、OFFSET、FETCH", sql);
+ }
+ Limit limit = plainSelect.getLimit();
+ if (first ? !isLimitOne(limit) || plainSelect.getDistinct() != null : limit != null) {
+ throw unsupported(first ? "首行仅支持 LIMIT 1,且不支持 DISTINCT" : "不支持 LIMIT", sql);
+ }
+ SimpleNode tableNode = table.getASTNode();
+ if (tableNode == null) {
+ throw unsupported("无法定位表", sql);
+ }
+
+ // 末尾的 FOR UPDATE:表之后的 token 中,最后两个(不含分号)
+ List tokens = new ArrayList<>();
+ for (Token token = tableNode.jjtGetLastToken().next; token != null && token.kind != CCJSqlParserConstants.EOF;
+ token = token.next) {
+ tokens.add(token);
+ }
+ while (!tokens.isEmpty() && tokens.get(tokens.size() - 1).kind == CCJSqlParserConstants.ST_SEMICOLON) {
+ tokens.remove(tokens.size() - 1);
+ }
+ if (tokens.size() < 2 || tokens.get(tokens.size() - 2).kind != CCJSqlParserConstants.K_FOR
+ || tokens.get(tokens.size() - 1).kind != CCJSqlParserConstants.K_UPDATE) {
+ throw unsupported("FOR UPDATE 不在末尾", sql);
+ }
+
+ // 删除 FOR UPDATE,在表(含别名)之后增加提示
+ int[] lineStarts = SqlTokenUtils.lineStarts(sql);
+ Token forToken = tokens.get(tokens.size() - 2);
+ Token updateToken = tokens.get(tokens.size() - 1);
+ int tableEnd = SqlTokenUtils.end(sql, lineStarts, tableNode.jjtGetLastToken());
+ List patches = new ArrayList<>();
+ patches.add(new SqlTokenUtils.Patch(SqlTokenUtils.begin(sql, lineStarts, forToken),
+ SqlTokenUtils.end(sql, lineStarts, updateToken), ""));
+ patches.add(new SqlTokenUtils.Patch(tableEnd, tableEnd, SQL_SERVER_LOCK_HINTS));
+ // 首行:删除 LIMIT 1,在 SELECT 之后增加 TOP (1)
+ if (first) {
+ SimpleNode limitNode = limit.getASTNode();
+ SimpleNode selectNode = plainSelect.getASTNode();
+ if (limitNode == null || selectNode == null
+ || selectNode.jjtGetFirstToken().kind != CCJSqlParserConstants.K_SELECT) {
+ throw unsupported("无法定位 SELECT、LIMIT", sql);
+ }
+ int selectEnd = SqlTokenUtils.end(sql, lineStarts, selectNode.jjtGetFirstToken());
+ patches.add(new SqlTokenUtils.Patch(selectEnd, selectEnd, SQL_SERVER_TOP_ONE));
+ patches.add(new SqlTokenUtils.Patch(SqlTokenUtils.begin(sql, lineStarts, limitNode.jjtGetFirstToken()),
+ SqlTokenUtils.end(sql, lineStarts, limitNode.jjtGetLastToken()), ""));
+ }
+ return SqlTokenUtils.apply(sql, patches);
+ }
+
+ private static boolean isLimitOne(Limit limit) {
+ if (limit == null || limit.getOffset() != null) {
+ return false;
+ }
+ Expression rowCount = limit.getRowCount();
+ return rowCount instanceof LongValue && ((LongValue) rowCount).getValue() == 1L;
+ }
+
+ private static IllegalStateException unsupported(String reason, String sql) {
+ return new IllegalStateException("SQL Server 不支持的行锁查询(" + reason + "):" + sql);
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java
new file mode 100644
index 0000000..292e4d7
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java
@@ -0,0 +1,144 @@
+package cn.iocoder.yudao.framework.mybatis.core.interceptor;
+
+import com.baomidou.mybatisplus.extension.parser.JsqlParserGlobal;
+import lombok.Value;
+import lombok.experimental.Accessors;
+import lombok.experimental.UtilityClass;
+import net.sf.jsqlparser.JSQLParserException;
+import net.sf.jsqlparser.parser.CCJSqlParserUtil;
+import net.sf.jsqlparser.parser.Token;
+import net.sf.jsqlparser.statement.Statement;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 按 JSqlParser 的 token 位置修改原 SQL 的工具类
+ *
+ * 只替换识别出的 token,不使用 JSqlParser 重新生成整条 SQL,保留原有的字符串、注释、提示与格式
+ *
+ * @author 芋道源码
+ */
+@UtilityClass
+class SqlTokenUtils {
+
+ /**
+ * 解析 SQL,保留 token 位置
+ *
+ * 注意:不使用 {@link JsqlParserGlobal#parse(String)},其缓存返回反序列化后的语句,不包含 token 位置,也不能被修改
+ *
+ * @param sql SQL
+ * @return 语句
+ * @throws JSQLParserException 解析失败
+ */
+ static Statement parse(String sql) throws JSQLParserException {
+ return CCJSqlParserUtil.parse(sql, JsqlParserGlobal.getExecutorService(), null);
+ }
+
+ /**
+ * 计算每行的起始位置,与 JavaCC 的行号规则一致:\r\n、\r、\n 均为换行
+ *
+ * @param sql SQL
+ * @return 每行的起始位置,下标为行号减 1
+ */
+ static int[] lineStarts(String sql) {
+ List starts = new ArrayList<>();
+ starts.add(0);
+ for (int i = 0; i < sql.length(); i++) {
+ char c = sql.charAt(i);
+ if (c == '\r' && i + 1 < sql.length() && sql.charAt(i + 1) == '\n') {
+ i++;
+ starts.add(i + 1);
+ } else if (c == '\r' || c == '\n') {
+ starts.add(i + 1);
+ }
+ }
+ return starts.stream().mapToInt(Integer::intValue).toArray();
+ }
+
+ /**
+ * 获得 token 在 SQL 中的起始位置,并校验原文与 token 一致
+ *
+ * @param sql SQL
+ * @param lineStarts {@link #lineStarts(String)} 的结果
+ * @param token token
+ * @return 起始位置
+ */
+ static int begin(String sql, int[] lineStarts, Token token) {
+ if (token.beginLine < 1 || token.beginLine > lineStarts.length) {
+ throw new IllegalStateException("无法定位 SQL 中的 token:" + token.image);
+ }
+ // JavaCC 的列号从 1 开始,制表符按 1 列计算
+ int offset = lineStarts[token.beginLine - 1] + token.beginColumn - 1;
+ if (offset < 0 || !sql.startsWith(token.image, offset)) {
+ throw new IllegalStateException("无法定位 SQL 中的 token:" + token.image);
+ }
+ return offset;
+ }
+
+ /**
+ * 获得 token 在 SQL 中的结束位置(不含)
+ *
+ * @param sql SQL
+ * @param lineStarts {@link #lineStarts(String)} 的结果
+ * @param token token
+ * @return 结束位置
+ */
+ static int end(String sql, int[] lineStarts, Token token) {
+ return begin(sql, lineStarts, token) + token.image.length();
+ }
+
+ /**
+ * 按位置替换 SQL;相同位置的相同替换只执行一次,其它重叠的替换视为错误
+ *
+ * @param sql SQL
+ * @param patches 替换
+ * @return 替换后的 SQL
+ */
+ static String apply(String sql, List patches) {
+ List sorted = new ArrayList<>(patches);
+ sorted.sort(Comparator.comparingInt(Patch::start).thenComparingInt(Patch::end));
+ List distinct = new ArrayList<>();
+ for (Patch patch : sorted) {
+ Patch last = distinct.isEmpty() ? null : distinct.get(distinct.size() - 1);
+ if (patch.equals(last)) {
+ continue;
+ }
+ if (last != null && patch.start() < last.end()) {
+ throw new IllegalStateException("SQL 修改位置重叠:" + last + "、" + patch);
+ }
+ distinct.add(patch);
+ }
+ // 从后向前替换,前面的位置不受影响
+ StringBuilder builder = new StringBuilder(sql);
+ for (int i = distinct.size() - 1; i >= 0; i--) {
+ Patch patch = distinct.get(i);
+ builder.replace(patch.start(), patch.end(), patch.replacement());
+ }
+ return builder.toString();
+ }
+
+ /**
+ * 替换:将 [start, end) 替换为 replacement;start 等于 end 时为插入
+ */
+ @Value
+ @Accessors(fluent = true)
+ static class Patch {
+
+ /**
+ * 起始位置
+ */
+ int start;
+ /**
+ * 结束位置(不含)
+ */
+ int end;
+ /**
+ * 替换的文本
+ */
+ String replacement;
+
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
index 2b008bf..18a093b 100644
--- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
+++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
@@ -1,11 +1,15 @@
package cn.iocoder.yudao.framework.mybatis.core.mapper;
import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
import cn.iocoder.yudao.framework.common.pojo.SortingField;
+import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.common.util.object.PageUtils;
+import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod;
import cn.iocoder.yudao.framework.mybatis.core.util.CaseInsensitiveResultMap;
import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
@@ -13,19 +17,25 @@ import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.segments.MergeSegments;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.metadata.MapperProxyMetadata;
import com.baomidou.mybatisplus.core.toolkit.Constants;
+import com.baomidou.mybatisplus.core.toolkit.MybatisUtils;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.extension.toolkit.Db;
import com.github.yulichang.base.MPJBaseMapper;
import com.github.yulichang.interfaces.MPJBaseJoin;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.binding.MapperMethod;
+import org.apache.ibatis.exceptions.TooManyResultsException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
/**
* 在 MyBatis Plus 的 BaseMapper 的基础上拓展,提供更多的能力
@@ -141,16 +151,26 @@ public interface BaseMapperX extends MPJBaseMapper {
return selectOne(new LambdaQueryWrapper().eq(field1, value1).eq(field2, value2).eq(field3, value3));
}
+ // TODO DONE @AI:能不能把现有的 selectOneForUpdate、selectFirstOneForUpdate、selectListForFirstLock、 这些,做一个整合。目前感觉有点散。。。不然有点乱;包括 cms 项目里,其实也有一些 lock;/Users/yunai/Java/yudao-cms-all/ruoyi-vue-pro-jdk25/yudao-module-cms;最好有个比较万金油的方案。。。。
+ // 已整合为 selectOneForUpdate、selectListForUpdate、selectFirstOneForUpdate 三个锁定读入口(含字段重载),boot、cms 的手写 FOR UPDATE 均改为调用它们;
+ // 锁定子句由内置查询 SelectForUpdateMethod 生成(不再暴露 selectListForFirstLock 等接口方法),SQL Server 由 ForUpdateInnerInterceptor 改写,Oracle 的首行锁定在本类按数据库类型走多次查询。
+
+ // ========== 锁定读:需要在事务中调用;Wrapper 不能使用 last、groupBy、having、参数别名 ==========
+
/**
* 获得满足条件的一条记录,并使用 FOR UPDATE 锁定。
*
- * 注意:需要在事务中调用,否则锁会立即释放。
+ * 注意:需要在事务中调用,否则锁会立即释放;多于 1 条时抛出 {@link TooManyResultsException}(已锁定的记录在事务结束时释放)
*
* @param queryWrapper 查询条件
* @return 实体
*/
default T selectOneForUpdate(LambdaQueryWrapper queryWrapper) {
- return selectOne(queryWrapper.last("FOR UPDATE"));
+ List list = selectListForUpdate(queryWrapper);
+ if (list.size() > 1) {
+ throw new TooManyResultsException("Expected one result (or null) to be returned by selectOneForUpdate(), but found: " + list.size());
+ }
+ return CollUtil.getFirst(list);
}
default T selectOneForUpdate(SFunction field, Object value) {
@@ -167,15 +187,112 @@ public interface BaseMapperX extends MPJBaseMapper {
}
/**
+ * 获得满足条件的全部记录,并使用 FOR UPDATE 锁定。
+ *
+ * 注意:需要在事务中调用,否则锁会立即释放;返回顺序为 queryWrapper 的排序,未设置时不排序
+ *
+ * @param queryWrapper 查询条件
+ * @return 实体列表
+ */
+ default List selectListForUpdate(LambdaQueryWrapper queryWrapper) {
+ return selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, null);
+ }
+
+ default List selectListForUpdate(SFunction field, Object value) {
+ return selectListForUpdate(new LambdaQueryWrapper().eq(field, value));
+ }
+
+ default List selectListForUpdate(SFunction field, Collection> values) {
+ if (CollUtil.isEmpty(values)) {
+ return CollUtil.newArrayList();
+ }
+ return selectListForUpdate(new LambdaQueryWrapper().in(field, values));
+ }
+
+ /**
* 获得满足条件的第 1 条记录,并使用 FOR UPDATE 锁定。
*
- * 注意:需要在事务中调用,否则锁会立即释放。
+ * 注意:需要在事务中调用,否则锁会立即释放;queryWrapper 未设置排序时,为任意一条满足条件的记录(只适合判断是否存在)
+ *
+ * Oracle 不支持 LIMIT 1 FOR UPDATE(Oracle 的 FOR UPDATE 也不能与行数限制一起使用),改为多次查询:
+ * 1. 按原条件、原排序查询首行主键(候选),不加锁;不存在时返回 null
+ * 2. 按原条件、且主键等于候选主键加锁查询,不限制行数;候选已不满足条件时重试
+ * 3. 锁定后重新查询首行主键,与候选相同才返回;不同(例如其它事务已提交了排序更靠前的记录)时重试,最多
+ * 5 次后抛出 {@link GlobalErrorCodeConstants#LOCKED}
+ * 要求实际执行的连接隔离级别为 READ COMMITTED,否则锁定后无法读到其它事务已提交的记录,
+ * 由 {@link cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor} 校验
+ *
+ * 其它数据库为单条 LIMIT 1 FOR UPDATE(SQL Server 改写为 TOP (1) 与 WITH (UPDLOCK, ROWLOCK))
*
* @param queryWrapper 查询条件
* @return 实体
*/
default T selectFirstOneForUpdate(LambdaQueryWrapper queryWrapper) {
- return selectOne(queryWrapper.last("LIMIT 1 FOR UPDATE"));
+ DbType dbType = JdbcUtils.getDbType();
+ if (!ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) {
+ return CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK_FIRST, queryWrapper, null));
+ }
+ int maxAttempts = 5;
+ for (int i = 0; i < maxAttempts; i++) {
+ List