From a38811c937b36e2afba97b27087a612a1106108e Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 29 Oct 2026 19:25:43 +0800 Subject: [PATCH] fix: 完善多数据库统计查询与限量清理兼容 --- yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java | 5 +++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnum.java | 10 +++++++++- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java | 33 +++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/QueryWrapperX.java | 26 -------------------------- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java | 28 +++++++++++++++++++++++++++- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java | 673 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnumTest.java | 9 +++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperXTest.java | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtilsTest.java | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java | 720 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/mysql/model/AiApiKeyMapper.java | 10 ++++------ yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/mysql/model/AiChatMapper.java | 10 ++++------ yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/home/Ai1HomeMapper.java | 9 +++++---- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMessageMapper.java | 34 +++++++++++++++------------------- yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractConfigMapper.java | 4 ++-- yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/customer/CrmCustomerPoolConfigMapper.java | 3 ++- yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java | 5 ++--- yudao-module-crm/src/main/resources/mapper/permission/CrmOwnerRecordMapper.xml | 8 ++++---- yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsCustomerMapper.xml | 30 +++++++++++++++--------------- yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsFunnelMapper.xml | 8 ++++---- yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsPerformanceMapper.xml | 16 ++++++++-------- yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsPerformanceTargetMapper.xml | 12 ++++++------ yudao-module-erp/src/main/resources/mapper/statistics/ErpPurchaseStatisticsMapper.xml | 1 + yudao-module-erp/src/main/resources/mapper/statistics/ErpSaleStatisticsMapper.xml | 1 + yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/info/HrmEmployeeMapper.java | 7 ++++--- yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/recruit/candidate/HrmRecruitInterviewMapper.java | 5 ++--- yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/salary/config/HrmSalaryConfigMapper.java | 5 ++--- yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/salary/config/HrmSalaryOptionMapper.java | 3 ++- yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/salary/monthrecord/HrmSalaryMonthRecordMapper.java | 5 ++--- yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/salary/slip/HrmSalarySlipTemplateMapper.java | 11 +++++------ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java | 3 +-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java | 3 +-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapper.java | 9 ++++----- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupRequestMapper.java | 3 +-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImChannelMessageMapper.java | 5 ++--- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java | 25 +++++++++++-------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java | 29 +++++++++++++---------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapper.java | 19 ++++++++++--------- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/job/JobLogMapper.java | 2 +- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/logger/ApiAccessLogMapper.java | 2 +- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/logger/ApiErrorLogMapper.java | 2 +- yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/bargain/BargainActivityMapper.java | 4 ++-- yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/bargain/BargainRecordMapper.java | 9 ++++----- yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/combination/CombinationRecordMapper.java | 10 ++++------ yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/coupon/CouponTemplateMapper.java | 5 ++--- yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/kefu/KeFuMessageMapper.java | 11 +++++------ yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/member/MemberStatisticsMapper.xml | 4 ++-- yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/TradeOrderStatisticsMapper.xml | 10 ++++------ yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/signin/MemberSignInRecordMapper.java | 8 +++----- yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/batch/MesWmBatchMapper.java | 3 +-- yudao-module-mes/src/main/resources/mapper/home/MesHomeStatisticsMapper.xml | 4 ++-- yudao-module-mes/src/main/resources/mapper/qc/pendinginspect/MesQcPendingInspectMapper.xml | 3 ++- yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemActivityMapper.java | 5 ++--- yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/notify/NotifyMessageMapper.java | 11 +++++------ yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java | 2 +- yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java | 2 +- yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/sms/SmsCodeMapper.java | 13 ++++++------- yudao-module-wms/src/main/resources/mapper/home/WmsHomeStatisticsMapper.xml | 20 ++++++++++---------- 60 files changed, 2267 insertions(+), 261 deletions(-) create mode 100644 yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java create mode 100644 yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperXTest.java create mode 100644 yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java create mode 100644 yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java mode change 100755 => 100644 yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/coupon/CouponTemplateMapper.java diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java index b05b3c0..496f3b1 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java @@ -15,6 +15,11 @@ import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_ public class TenantUtils { /** + * 平台共享数据的租户编号;不是未设置租户上下文时的 null + */ + public static final Long GLOBAL_TENANT_ID = 0L; + + /** * 使用指定租户,执行对应的逻辑 * * 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户 diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnum.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnum.java index 2742021..9f005cb 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnum.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnum.java @@ -107,11 +107,19 @@ public enum DbTypeEnum { */ private final String findInSetTemplate; + /** + * 根据数据库产品名获得 MyBatis Plus 类型 + * + * @param databaseProductName 数据库产品名 + * @return MyBatis Plus 类型;产品名为空或未登记时返回 null + */ public static DbType find(String databaseProductName) { if (StrUtil.isBlank(databaseProductName)) { return null; } - return MAP_BY_NAME.get(databaseProductName).getMpDbType(); + return Optional.ofNullable(MAP_BY_NAME.get(databaseProductName)) + .map(DbTypeEnum::getMpDbType) + .orElse(null); } public static String getFindInSetTemplate(DbType dbType) { 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 3b02c54..2b008bf 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 @@ -216,6 +216,39 @@ public interface BaseMapperX extends MPJBaseMapper { return CollUtil.getLast(selectList(queryWrapper)); } + /** + * 获取满足条件的第 1 条记录,见 {@link #selectListLimitN(Wrapper, int)} + * + * @param queryWrapper 查询条件 + * @return 第 1 条;不存在返回 null + */ + default T selectFirstOne(Wrapper queryWrapper) { + return CollUtil.getFirst(selectListLimitN(queryWrapper, 1)); + } + + /** + * 获取满足条件的前 limit 条记录 + * + * 使用 MyBatis Plus 分页插件,按执行时连接的数据库类型生成 LIMIT、ROWNUM、OFFSET FETCH 等语句,并且不执行 COUNT 查询; + * 保留 queryWrapper 的排序与 select 投影,不能再通过 last 追加行数限制 + * + * 注意:SQL Server 的分页语句要求有 ORDER BY,queryWrapper 需要设置排序 + * + * @param queryWrapper 查询条件 + * @param limit 条数,不能为负数;为 0 时不查询,直接返回空列表 + * @return 记录列表 + */ + default List selectListLimitN(Wrapper queryWrapper, int limit) { + if (limit < 0) { + // 分页插件会把负数的 size 视为不分页,从而查询全表 + throw new IllegalArgumentException("limit 不能为负数:" + limit); + } + if (limit == 0) { + return CollUtil.newArrayList(); + } + return selectPage(MyBatisUtils.buildPage(0, limit, false), queryWrapper).getRecords(); + } + default Long selectCount() { return selectCount(new QueryWrapper<>()); } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/QueryWrapperX.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/QueryWrapperX.java index 525d575..449a37e 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/QueryWrapperX.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/QueryWrapperX.java @@ -1,7 +1,5 @@ package cn.iocoder.yudao.framework.mybatis.core.query; -import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; -import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.toolkit.ArrayUtils; import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; @@ -139,28 +137,4 @@ public class QueryWrapperX extends QueryWrapper { return this; } - /** - * 设置只返回最后一条 - * - * TODO 芋艿:不是完美解,需要在思考下。如果使用多数据源,并且数据源是多种类型时,可能会存在问题:实现之返回一条的语法不同 - * - * @return this - */ - public QueryWrapperX limitN(int n) { - DbType dbType = JdbcUtils.getDbType(); - switch (dbType) { - case ORACLE: - case ORACLE_12C: - super.le("ROWNUM", n); - break; - case SQL_SERVER: - case SQL_SERVER2005: - super.select("TOP " + n + " *"); // 由于 SQL Server 是通过 SELECT TOP 1 实现限制一条,所以只好使用 * 查询剩余字段 - break; - default: // MySQL、PostgreSQL、DM 达梦、KingbaseES 大金都是采用 LIMIT 实现 - super.last("LIMIT " + n); - } - return this; - } - } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java index ccfe166..39dbe0e 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java @@ -1,16 +1,23 @@ package cn.iocoder.yudao.framework.mybatis.core.util; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; import cn.iocoder.yudao.framework.common.util.spring.SpringUtils; import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum; import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; import com.baomidou.mybatisplus.annotation.DbType; import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.jdbc.datasource.ConnectionHolder; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.ConcurrentReferenceHashMap; import javax.sql.DataSource; import java.sql.Connection; +import java.sql.DatabaseMetaData; import java.sql.DriverManager; import java.sql.SQLException; +import java.util.Locale; +import java.util.Map; /** * JDBC 工具类 @@ -20,6 +27,12 @@ import java.sql.SQLException; public class JdbcUtils { /** + * 数据源与 DB 类型的缓存;弱引用数据源,动态移除的数据源可被回收 + */ + private static final Map DB_TYPE_CACHE = + new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); + + /** * 判断连接是否正确 * * @param url 数据源连接 @@ -55,26 +68,127 @@ public class JdbcUtils { } /** + * 获得当前执行时路由到的数据源 + * + * 存在动态数据源时,按 {@link DynamicRoutingDataSource#determineDataSource()} 获取当前线程路由到的数据源;否则使用唯一的数据源 + * + * @return 数据源 + */ + public static DataSource getDataSource() { + DataSource dataSource = getRoutingDataSource(); + return dataSource instanceof DynamicRoutingDataSource + ? ((DynamicRoutingDataSource) dataSource).determineDataSource() : dataSource; + } + + /** + * 取得注册的路由数据源;没有动态数据源时使用唯一的数据源。 + * + * @return 路由数据源或唯一数据源 + */ + private static DataSource getRoutingDataSource() { + try { + return SpringUtils.getBean(DynamicRoutingDataSource.class); + } catch (NoSuchBeanDefinitionException e) { + return SpringUtils.getBean(DataSource.class); + } + } + + /** + * 获得当前数据源上 Spring 事务已绑定的连接,不借出新连接,也不转移连接的关闭责任。 + * 动态数据源的事务资源绑定在路由数据源上;事务内再次切换路由键,不会改变已绑定的执行连接。 + */ + static Connection getTransactionalConnection() { + Object resource = TransactionSynchronizationManager.getResource(getRoutingDataSource()); + return resource instanceof ConnectionHolder ? ((ConnectionHolder) resource).getConnection() : null; + } + + /** * 通过当前数据库连接获得对应的 DB 类型 * + * 事务内复用已绑定连接;其它情况按当前路由到的数据源实例缓存,命中缓存时不再获取连接 + * * @return DB 类型 */ public static DbType getDbType() { - DataSource dataSource; - try { - DynamicRoutingDataSource dynamicRoutingDataSource = SpringUtils.getBean(DynamicRoutingDataSource.class); - dataSource = dynamicRoutingDataSource.determineDataSource(); - } catch (NoSuchBeanDefinitionException e) { - dataSource = SpringUtils.getBean(DataSource.class); + Connection connection = getTransactionalConnection(); + if (connection != null) { + try { + return detectDbType(connection); + } catch (SQLException e) { + throw new IllegalArgumentException(e.getMessage()); + } } + return getDbType(getDataSource()); + } + + // TODO DONE @AI:方法注释;已说明连接归属、缓存或数据源选择。 + /** + * 按实际数据源实例缓存数据库类型;检测失败时不写入缓存。 + * + * @param dataSource 实际路由到的数据源 + * @return 数据库类型 + */ + static DbType getDbType(DataSource dataSource) { + return DB_TYPE_CACHE.computeIfAbsent(dataSource, JdbcUtils::detectDbType); + } + + /** + * 通过自行借用的连接识别数据库类型,并在结束后关闭该连接。 + * + * @param dataSource 待识别的数据源 + * @return 数据库类型 + */ + private static DbType detectDbType(DataSource dataSource) { try (Connection conn = dataSource.getConnection()) { - return DbTypeEnum.find(conn.getMetaData().getDatabaseProductName()); + return detectDbType(conn); } catch (SQLException e) { throw new IllegalArgumentException(e.getMessage()); } } /** + * 读取执行连接的元数据识别数据库类型,不关闭调用方持有的连接。 + * + * @param connection 当前执行连接 + * @return 数据库类型;产品名为空时保留原有 null 返回 + * @throws SQLException 读取数据库元数据失败 + */ + private static DbType detectDbType(Connection connection) throws SQLException { + DatabaseMetaData metaData = connection.getMetaData(); + String productName = metaData.getDatabaseProductName(); + DbType dbType = DbTypeEnum.find(productName); + if (dbType != null || StrUtil.isBlank(productName)) { + return dbType; + } + // 不使用 MyBatis Plus 的 URL 识别,避免其在日志中打印完整 URL + dbType = findDbTypeByNativeUrl(metaData.getURL()); + if (dbType == null) { + throw new IllegalArgumentException("不支持的数据库产品:" + productName); + } + return dbType; + } + + /** + * 按原生驱动 URL 识别产品名未登记的数据库,与 {@link #getDbType(String)} 的归类保持一致 + * + * @param url JDBC URL + * @return DB 类型;无法识别时返回 null + */ + private static DbType findDbTypeByNativeUrl(String url) { + if (StrUtil.isBlank(url)) { + return null; + } + String lowerUrl = url.toLowerCase(Locale.ROOT); + if (lowerUrl.contains(":highgo:")) { + return DbType.POSTGRE_SQL; + } + if (lowerUrl.contains(":mariadb:")) { + return DbType.MARIADB; + } + return null; + } + + /** * 判断 JDBC 连接是否为 SQLServer 数据库 * * @param url JDBC 连接 @@ -95,4 +209,6 @@ public class JdbcUtils { return ObjectUtils.equalsAny(dbType, DbType.SQL_SERVER, DbType.SQL_SERVER2005); } + + } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java index 1cdf7f1..4d49766 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java @@ -61,6 +61,26 @@ public class MyBatisUtils { return page; } + /** + * 构建指定偏移量的分页对象 + * + * @param offset 偏移量,负数按 0 处理,不要求是每页条数的整数倍 + * @param size 每页条数 + * @param searchCount 是否查询总数 + * @param 记录类型 + * @return 分页对象 + */ + public static Page buildPage(long offset, long size, boolean searchCount) { + return new Page(1, size, searchCount) { + + @Override + public long offset() { + return Math.max(offset, 0); + } + + }; + } + @SuppressWarnings("PatternVariableCanBeUsed") public static void addOrder(Wrapper wrapper, Collection sortingFields) { if (CollUtil.isEmpty(sortingFields)) { @@ -220,7 +240,13 @@ public class MyBatisUtils { .replace(FIND_IN_SET_VALUE_PLACEHOLDER, "{" + paramIndex + "}"); } - private static boolean isSafeColumnName(String columnName) { + /** + * 判断表名或列名是否只包含字母、数字、下划线及层级分隔点 + * + * @param columnName 表名或列名,可带表别名前缀 + * @return 是否满足安全标识符格式 + */ + static boolean isSafeColumnName(String columnName) { return StrUtil.isNotEmpty(columnName) && SAFE_COLUMN_NAME_PATTERN.matcher(columnName).matches(); } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java new file mode 100644 index 0000000..930dd5b --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java @@ -0,0 +1,673 @@ +package cn.iocoder.yudao.framework.mybatis.core.util; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum; +import com.baomidou.mybatisplus.annotation.DbType; +import org.springframework.util.ConcurrentReferenceHashMap; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +// TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 +// TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。 +/** + * 日期统计、行数限制、分批删除 SQL 片段的方言工具类 + * + * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库 + * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、 + * 达梦、神通、人大金仓、openGauss、GaussDB、瀚高、MogDB 等)保持原有 SQL 片段;完全未识别的数据库抛出异常 + * 3. 参数只能是代码中固定的列名,经过白名单校验;格式均为常量,业务值必须继续使用 #{} 绑定 + * + * 在 XML 或注解中使用:${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@formatDate('create_time')} + * + * @author 芋道源码 + */ +@SuppressWarnings("EnhancedSwitchMigration") +public class SqlDialectUtils { + + /** + * SQL Server 2012 的主版本号 + */ + private static final int SQL_SERVER_2012_MAJOR_VERSION = 11; + + /** + * 保持原有 SQL 片段的数据库类型 + */ + private static final Set ORIGINAL_DB_TYPES = EnumSet.of(DbType.MYSQL, DbType.OCEAN_BASE, + DbType.H2, DbType.DM, DbType.OSCAR, DbType.KINGBASE_ES, DbType.OPENGAUSS, DbType.GAUSS_DB, + DbType.SQL_SERVER2005); + + /** + * 兼容 PostgreSQL 的原生驱动 URL:瀚高、openGauss、GaussDB、MogDB、人大金仓 + */ + private static final Pattern POSTGRESQL_VARIANT_URL_PATTERN = + Pattern.compile(":(highgo|opengauss|gaussdb|mogdb|kingbase\\d*):"); + + /** + * MyBatis 参数名:参数或其属性路径,例如 limit、reqVO.limit + */ + private static final Pattern PARAM_NAME_PATTERN = + Pattern.compile("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*"); + + /** + * 数据源与方言的缓存;弱引用数据源,动态移除的数据源可被回收 + */ + private static final Map DIALECT_CACHE = + new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); + + /** + * 方言 + */ + enum Dialect { + + /** + * 原有 SQL 片段 + */ + ORIGINAL, + POSTGRESQL, + ORACLE, + SQLSERVER + + } + + // ========== 日期字符串 ========== + + /** + * 按天格式化为 yyyy-MM-dd 字符串;原有片段为 DATE_FORMAT(column, '%Y-%m-%d') + * + * @param column 列名 + * @return SQL 片段 + */ + public static String formatDate(String column) { + return formatDate(getDialect(), column); + } + + /** + * 按指定方言生成 yyyy-MM-dd 字符串,保留各分支原有格式。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return SQL 片段 + */ + static String formatDate(Dialect dialect, String column) { + checkColumn(column); + switch (dialect) { + case POSTGRESQL: + case ORACLE: + return "TO_CHAR(" + column + ", 'YYYY-MM-DD')"; + case SQLSERVER: + return "CONVERT(varchar(10), " + column + ", 23)"; + default: + return "DATE_FORMAT(" + column + ", '%Y-%m-%d')"; + } + } + + /** + * 按月格式化为 yyyyMM 字符串;原有片段为 DATE_FORMAT(column, '%Y%m') + * + * @param column 列名 + * @return SQL 片段 + */ + // TODO DONE @AI:用不到的,可以暂时先去掉噢; CRM 业绩统计使用 yyyyMM,商城交易统计使用 yyyy-MM,均需保留。 + public static String formatYearMonth(String column) { + return formatYearMonth(getDialect(), column); + } + + /** + * 按指定方言生成 yyyyMM 字符串,供 CRM 按月统计使用。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return SQL 片段 + */ + static String formatYearMonth(Dialect dialect, String column) { + checkColumn(column); + switch (dialect) { + case POSTGRESQL: + case ORACLE: + return "TO_CHAR(" + column + ", 'YYYYMM')"; + case SQLSERVER: + return "CONVERT(varchar(6), " + column + ", 112)"; + default: + return "DATE_FORMAT(" + column + ", '%Y%m')"; + } + } + + /** + * 按月格式化为 yyyy-MM 字符串;原有片段为 CONCAT(EXTRACT(YEAR ...), '-', LPAD(...)) + * + * @param column 列名 + * @return SQL 片段 + */ + // TODO DONE @AI:用不到的,可以暂时先去掉噢; CRM 业绩统计使用 yyyyMM,商城交易统计使用 yyyy-MM,均需保留。 + public static String formatYearDashMonth(String column) { + return formatYearDashMonth(getDialect(), column); + } + + /** + * 按指定方言生成 yyyy-MM 字符串,供交易统计月份标签使用。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return SQL 片段 + */ + static String formatYearDashMonth(Dialect dialect, String column) { + checkColumn(column); + switch (dialect) { + case POSTGRESQL: + case ORACLE: + return "TO_CHAR(" + column + ", 'YYYY-MM')"; + case SQLSERVER: + return "CONVERT(varchar(7), " + column + ", 120)"; + default: + return "CONCAT(EXTRACT(YEAR FROM " + column + "), '-', LPAD(CONCAT(EXTRACT(MONTH FROM " + column + ")), 2, '0'))"; + } + } + + /** + * 按天分组;原有片段为 DATE(column),新方言输出 yyyy-MM-dd 字符串 + * + * 适用于同时兼容日期值与 yyyy-MM-dd 字符串的消费者 + * + * @param column 列名 + * @return SQL 片段 + */ + public static String date(String column) { + return date(getDialect(), column); + } + + /** + * 生成按天分组表达式;原路径保留 DATE,新分支返回日期文本。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return SQL 片段 + */ + static String date(Dialect dialect, String column) { + checkColumn(column); + if (dialect == Dialect.ORIGINAL) { + return "DATE(" + column + ")"; + } + return formatDate(dialect, column); + } + + // TODO DONE @AI:用不到的,可以先清理掉噢; AI1 首页与交易统计的 String 日期消费者仍在使用,保留。 + /** + * 按天分组;原有片段为 CAST(column AS DATE),新方言输出 yyyy-MM-dd 字符串 + * + * 适用于 String 类型的消费者 + * + * @param column 列名 + * @return SQL 片段 + */ + public static String castDateText(String column) { + return castDateText(getDialect(), column); + } + + /** + * 生成 String 日期消费者所需的转换表达式,保留原路径 CAST AS DATE。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return SQL 片段 + */ + static String castDateText(Dialect dialect, String column) { + checkColumn(column); + if (dialect == Dialect.ORIGINAL) { + return "CAST(" + column + " AS DATE)"; + } + return formatDate(dialect, column); + } + + // ========== 日期值 ========== + + /** + * 按天截断为日期值;原有片段为 CAST(column AS DATE) + * + * 适用于 LocalDate 类型的消费者 + * + * @param column 列名 + * @return SQL 片段 + */ + // TODO DONE @AI:多余的是不是去掉? 会员统计的 LocalDate 消费者仍在使用,保留 DATE 类型转换。 + public static String castDate(String column) { + return castDate(getDialect(), column); + } + + // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 + /** + * 生成 LocalDate 消费者所需的日期值;Oracle 用 TRUNC 去掉时分秒。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return SQL 片段 + */ + static String castDate(Dialect dialect, String column) { + checkColumn(column); + if (dialect == Dialect.ORACLE) { + // Oracle 的 DATE 包含时分秒,CAST AS DATE 不会截断 + return "TRUNC(" + column + ")"; + } + return "CAST(" + column + " AS DATE)"; + } + + // ========== 日期部件 ========== + + /** + * 月份(1-12);原有片段为 MONTH(column) + * + * @param column 列名 + * @return SQL 片段 + */ + public static String month(String column) { + return month(getDialect(), column); + } + + /** + * 按指定方言提取月份。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return 月份 SQL 表达式 + */ + static String month(Dialect dialect, String column) { + return datePart(dialect, column, "MONTH"); + } + + /** + * 年份;原有片段为 YEAR(column) + * + * @param column 列名 + * @return SQL 片段 + */ + // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 + public static String year(String column) { + return year(getDialect(), column); + } + + /** + * 按指定方言提取年份,供 CRM 业绩目标筛选使用。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return 年份 SQL 表达式 + */ + static String year(Dialect dialect, String column) { + return datePart(dialect, column, "YEAR"); + } + + /** + * 日(1-31);原有片段为 DAYOFMONTH(column) + * + * @param column 列名 + * @return SQL 片段 + */ + public static String dayOfMonth(String column) { + return dayOfMonth(getDialect(), column); + } + + // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 + /** + * 按指定方言提取月内日,原路径保留 DAYOFMONTH。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @return 日 SQL 表达式 + */ + static String dayOfMonth(Dialect dialect, String column) { + checkColumn(column); + if (dialect == Dialect.ORIGINAL) { + return "DAYOFMONTH(" + column + ")"; + } + return datePart(dialect, column, "DAY"); + } + + /** + * 日期部件:PostgreSQL 的 EXTRACT 返回 numeric,需要转为整数;原有片段与 SQL Server 均为 field(column) + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的安全列名 + * @param field 代码中固定的 YEAR、MONTH 或 DAY + * @return 日期部件 SQL 表达式 + */ + private static String datePart(Dialect dialect, String column, String field) { + checkColumn(column); + switch (dialect) { + case POSTGRESQL: + return "CAST(EXTRACT(" + field + " FROM " + column + ") AS INTEGER)"; + case ORACLE: + return "EXTRACT(" + field + " FROM " + column + ")"; + default: + return field + "(" + column + ")"; + } + } + + // ========== 聚合 ========== + + /** + * 平均成交周期(天):两个时间之间的完整天数(向零截断)求平均,保留 1 位小数(截断),无数据时为 0 + * + * 原有片段为 COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, begin, end)), 1), 0)。 + * 注意:仅 PostgreSQL 输出新片段;Oracle、SQL Server 的完整天数表达式依赖实际列类型(DATE / TIMESTAMP / datetime2 / date 等), + * 尚未依据实际 DDL 在真实数据库验收,因此保持原有片段 + * + * @param beginColumn 开始时间列名 + * @param endColumn 结束时间列名 + * @return SQL 片段 + */ + public static String customerDealCycle(String beginColumn, String endColumn) { + return customerDealCycle(getDialect(), beginColumn, endColumn); + } + + /** + * 按指定方言生成平均完整天数;未验收的数据库继续使用原表达式。 + * + * @param dialect 已识别的数据库方言 + * @param beginColumn 代码中固定的开始时间列名 + * @param endColumn 代码中固定的结束时间列名 + * @return 平均成交周期 SQL 表达式 + */ + static String customerDealCycle(Dialect dialect, String beginColumn, String endColumn) { + checkColumn(beginColumn); + checkColumn(endColumn); + if (dialect == Dialect.POSTGRESQL) { + return "COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP) - CAST(" + + beginColumn + " AS TIMESTAMP))) AS NUMERIC)), 1), 0)"; + } + return "COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, " + beginColumn + ", " + endColumn + ")), 1), 0)"; + } + + // ========== 其它 ========== + + /** + * 顶层 SELECT 无 FROM 时的补充:Oracle 为 FROM DUAL,其它为空 + * + * @return SQL 片段 + */ + public static String fromDual() { + return fromDual(getDialect()); + } + + /** + * 为无顶层 FROM 的标量汇总补充 Oracle DUAL,其他分支保持空文本。 + * + * @param dialect 已识别的数据库方言 + * @return Oracle 为 FROM DUAL,其他为空字符串 + */ + static String fromDual(Dialect dialect) { + return dialect == Dialect.ORACLE ? "FROM DUAL" : ""; + } + + // ========== 行数限制 ========== + + /** + * 只返回前 rows 行,追加在语句末尾(ORDER BY 之后);原有片段为 LIMIT rows + * + * 仅用于 XML 或注解中手写的 SQL;使用 Wrapper 查询时,使用 BaseMapperX 的 selectListLimitN(queryWrapper, limit) 分页查询 + * + * 1. Oracle:FETCH FIRST rows ROWS ONLY,要求 Oracle 12c+ + * 2. SQL Server 2012+:OFFSET 0 ROWS FETCH NEXT rows ROWS ONLY;OFFSET 必须跟在 ORDER BY 之后, + * 语句没有排序时补充 ORDER BY (SELECT NULL),即不保证返回哪几行,与原有 LIMIT 无排序时一致 + * + * @param rows 行数,不能为负数;SQL Server 的 FETCH 不支持 0 行 + * @param ordered 语句是否已有 ORDER BY + * @return SQL 片段 + */ + public static String limitRows(int rows, boolean ordered) { + return limitRows(getDialect(), rows, ordered); + } + + /** + * 生成固定行数限制;负数拒绝,SQL Server 的 FETCH 不接受 0 行。 + * + * @param dialect 已识别的数据库方言 + * @param rows 固定行数 + * @param ordered 原查询是否已有排序 + * @return 追加在查询末尾的 SQL 片段 + */ + static String limitRows(Dialect dialect, int rows, boolean ordered) { + if (rows < 0) { + throw new IllegalArgumentException("行数不能为负数:" + rows); + } + if (rows == 0 && dialect == Dialect.SQLSERVER) { + throw new IllegalArgumentException("SQL Server 的 FETCH 行数必须大于 0"); + } + return limit(dialect, String.valueOf(rows), ordered); + } + + /** + * 只返回前 #{param} 行,行数使用 #{} 绑定;规则同 {@link #limitRows(int, boolean)} + * + * 在 XML 或注解中使用:${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@limitParam('limit', true)} + * + * @param param 行数的参数名,例如 limit + * @param ordered 语句是否已有 ORDER BY + * @return SQL 片段 + */ + public static String limitParam(String param, boolean ordered) { + return limitParam(getDialect(), param, ordered); + } + + /** + * 生成绑定参数的行数限制,参数名通过白名单校验。 + * + * @param dialect 已识别的数据库方言 + * @param param 行数参数名或属性路径 + * @param ordered 原查询是否已有排序 + * @return 包含 MyBatis 参数占位的 SQL 片段 + */ + static String limitParam(Dialect dialect, String param, boolean ordered) { + checkParam(param); + return limit(dialect, "#{" + param + "}", ordered); + } + + /** + * 拼装行数限制;rows 仅来自已校验的整数或 MyBatis 参数占位。 + * + * @param dialect 已识别的数据库方言 + * @param rows 固定整数文本或已校验的参数占位 + * @param ordered 原查询是否已有排序 + * @return 行数限制 SQL 片段 + */ + private static String limit(Dialect dialect, String rows, boolean ordered) { + switch (dialect) { + case ORACLE: + return "FETCH FIRST " + rows + " ROWS ONLY"; + case SQLSERVER: + return (ordered ? "" : "ORDER BY (SELECT NULL) ") + "OFFSET 0 ROWS FETCH NEXT " + rows + " ROWS ONLY"; + default: + return "LIMIT " + rows; + } + } + + // ========== 分批删除 ========== + + /** + * 分批删除 column < #{valueParam} 的记录,每次最多删除 #{limitParam} 行; + * 原有语句为 DELETE FROM table WHERE column < #{valueParam} LIMIT #{limitParam} + * + * 1. PostgreSQL:DELETE 不支持 LIMIT,按主键 id 的子查询选出一批 + * 2. Oracle:ROWNUM <= #{limitParam} + * 3. SQL Server 2012+:按主键 id 的子查询 SELECT TOP (#{limitParam})。 + * 不使用 DELETE TOP (n),因为 JSqlParser 无法解析,租户、数据权限插件解析 DELETE 时会失败 + * + * 要求表的主键列为 id;表名、列名只能是代码中固定的常量,值均使用 #{} 绑定 + * + * 在注解中使用:${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@deleteBatchByLessThan('infra_job_log', 'create_time', 'createTime', 'limit')} + * + * @param table 表名 + * @param column 比较的列名 + * @param valueParam 比较值的参数名 + * @param limitParam 每批行数的参数名 + * @return SQL 语句 + */ + public static String deleteBatchByLessThan(String table, String column, String valueParam, String limitParam) { + return deleteBatchByLessThan(getDialect(), table, column, valueParam, limitParam); + } + + /** + * 按方言生成主键为 id 的分批物理删除;当前用于忽略租户的全局清理任务。 + * + * @param dialect 已识别的数据库方言 + * @param table 代码中固定的表名 + * @param column 代码中固定的时间列名 + * @param valueParam 时间阈值参数名 + * @param limitParam 每批行数参数名 + * @return 参数绑定的 DELETE 语句 + */ + static String deleteBatchByLessThan(Dialect dialect, String table, String column, + String valueParam, String limitParam) { + checkColumn(table); + checkColumn(column); + checkParam(valueParam); + checkParam(limitParam); + String condition = column + " < #{" + valueParam + "}"; + String limit = "#{" + limitParam + "}"; + switch (dialect) { + case POSTGRESQL: + return "DELETE FROM " + table + " WHERE id IN (SELECT id FROM " + table + + " WHERE " + condition + " LIMIT " + limit + ")"; + case ORACLE: + return "DELETE FROM " + table + " WHERE " + condition + " AND ROWNUM <= " + limit; + case SQLSERVER: + return "DELETE FROM " + table + " WHERE id IN (SELECT TOP (" + limit + ") id FROM " + table + + " WHERE " + condition + ")"; + default: + return "DELETE FROM " + table + " WHERE " + condition + " LIMIT " + limit; + } + } + + // ========== 方言识别 ========== + + /** + * 取得当前执行方言;事务内复用已绑定连接,非事务按路由后的数据源缓存。 + * + * @return 当前执行方言 + */ + static Dialect getDialect() { + Connection connection = JdbcUtils.getTransactionalConnection(); + if (connection != null) { + try { + // 使用真正执行 SQL 的事务连接;不能将其方言缓存到事务内新切换的路由键上 + return resolveDialect(connection.getMetaData()); + } catch (SQLException e) { + throw new IllegalStateException("获取数据库方言失败", e); + } + } + return getDialect(JdbcUtils.getDataSource()); + } + + /** + * 按数据源实例缓存方言;识别失败时不写入缓存。 + * + * @param dataSource 实际路由到的数据源 + * @return 数据库方言 + */ + static Dialect getDialect(DataSource dataSource) { + return DIALECT_CACHE.computeIfAbsent(dataSource, SqlDialectUtils::detectDialect); + } + + /** + * 借用一个连接识别数据源方言,并关闭该次自行获取的连接。 + * + * @param dataSource 待识别的数据源 + * @return 数据库方言 + */ + private static Dialect detectDialect(DataSource dataSource) { + try (Connection conn = dataSource.getConnection()) { + return resolveDialect(conn.getMetaData()); + } catch (SQLException e) { + throw new IllegalStateException("获取数据库方言失败", e); + } + } + + /** + * 根据数据库元数据识别方言 + * + * 注意:使用 PostgreSQL 官方驱动(jdbc:postgresql:)连接 openGauss、瀚高等兼容 PostgreSQL 的数据库时, + * 产品名与 URL 均与 PostgreSQL 相同,无法区分,会按 PostgreSQL 处理 + * + * @param metaData 数据库元数据 + * @return 方言 + * @throws SQLException 读取元数据失败 + */ + static Dialect resolveDialect(DatabaseMetaData metaData) throws SQLException { + String productName = metaData.getDatabaseProductName(); + String url = StrUtil.nullToEmpty(metaData.getURL()).toLowerCase(Locale.ROOT); + DbType dbType = DbTypeEnum.find(productName); + if (dbType == DbType.ORACLE) { + return Dialect.ORACLE; + } + if (dbType == DbType.SQL_SERVER) { + return metaData.getDatabaseMajorVersion() >= SQL_SERVER_2012_MAJOR_VERSION + ? Dialect.SQLSERVER : Dialect.ORIGINAL; + } + if (dbType == DbType.POSTGRE_SQL) { + if (isPostgreSQLVariantUrl(url)) { + return Dialect.ORIGINAL; + } + if (url.contains(":postgresql:")) { + return Dialect.POSTGRESQL; + } + throw unsupported(productName); + } + if (ORIGINAL_DB_TYPES.contains(dbType)) { + return Dialect.ORIGINAL; + } + // 产品名未登记时,仅按明确的原生驱动 URL 识别瀚高、MogDB 等,以及 MariaDB + if (dbType == null && (isPostgreSQLVariantUrl(url) || url.contains(":mariadb:"))) { + return Dialect.ORIGINAL; + } + throw unsupported(productName); + } + + /** + * 判断 URL 是否为需要保留原 SQL 路径的 PostgreSQL 兼容产品驱动。 + * + * @param url 转为小写的 JDBC URL + * @return 是否命中已登记的兼容产品 URL + */ + private static boolean isPostgreSQLVariantUrl(String url) { + return POSTGRESQL_VARIANT_URL_PATTERN.matcher(url).find(); + } + + /** + * 创建未识别产品的异常,不将完整 JDBC URL 写入消息。 + * + * @param productName 数据库产品名 + * @return 不支持该产品的异常 + */ + private static IllegalStateException unsupported(String productName) { + return new IllegalStateException("SQL 方言不支持的数据库产品:" + productName); + } + + /** + * 校验代码中固定的表名或列名,拒绝表达式与语句片段。 + * + * @param column 待校验的表名或列名 + */ + private static void checkColumn(String column) { + if (!MyBatisUtils.isSafeColumnName(column)) { + throw new IllegalArgumentException("Invalid column name: " + column); + } + } + + /** + * 校验 MyBatis 参数名或属性路径,拒绝占位符逃逸与 SQL 片段。 + * + * @param param 待校验的参数名 + */ + private static void checkParam(String param) { + if (param == null || !PARAM_NAME_PATTERN.matcher(param).matches()) { + throw new IllegalArgumentException("Invalid param name: " + param); + } + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnumTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnumTest.java index 7a4698c..e5c5641 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnumTest.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/enums/DbTypeEnumTest.java @@ -25,6 +25,15 @@ public class DbTypeEnumTest { // 神通 JDBC 驱动(jdbc:oscar:)返回的 ProductName assertEquals(DbType.OSCAR, DbTypeEnum.find("OSCAR")); assertNull(DbTypeEnum.find("")); + assertNull(DbTypeEnum.find(null)); + } + + @Test + public void testFind_unregistered() { + // 未登记的产品名返回 null,而不是 NPE,由调用方给出明确的处理 + assertNull(DbTypeEnum.find("HighGo")); + assertNull(DbTypeEnum.find("MariaDB")); + assertNull(DbTypeEnum.find("SQLite")); } @Test diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperXTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperXTest.java new file mode 100644 index 0000000..602d65f --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperXTest.java @@ -0,0 +1,168 @@ +package cn.iocoder.yudao.framework.mybatis.core.mapper; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.DialectFactory; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link BaseMapperX} 限量查询的单元测试 + * + * 不连接数据库:通过 JDK 动态代理执行 default 方法,记录传给 MyBatis Plus 的分页参数 + * + * @author 芋道源码 + */ +public class BaseMapperXTest { + + @Test + public void testSelectListLimitN_limit() { + FakeMapper fake = new FakeMapper(List.of("a", "b")); + QueryWrapper wrapper = new QueryWrapper() + .select("id", "name") + .eq("status", 1) + .orderByDesc("id"); + String sqlSegment = wrapper.getSqlSegment(); + + // 调用 + List list = fake.mapper.selectListLimitN(wrapper, 5); + // 断言:第 1 页、每页 5 条、不执行 COUNT、不追加排序 + assertEquals(List.of("a", "b"), list); + assertEquals(1, fake.invocations); + assertEquals(1, fake.page.getCurrent()); + assertEquals(5, fake.page.getSize()); + assertFalse(fake.page.searchCount()); + assertTrue(fake.page.orders().isEmpty()); + // 断言:条件、排序、投影均不变,行数限制交给分页插件生成 + assertSame(wrapper, fake.wrapper); + assertEquals(sqlSegment, wrapper.getSqlSegment()); + assertEquals("id,name", wrapper.getSqlSelect()); + assertFalse(wrapper.getSqlSegment().toUpperCase().contains("LIMIT")); + } + + @Test + public void testSelectListLimitN_zero() { + FakeMapper fake = new FakeMapper(List.of("a")); + + // 调用,并断言:不查询,避免分页插件按不分页处理而查询全表 + List list = fake.mapper.selectListLimitN(new QueryWrapper().orderByDesc("id"), 0); + assertTrue(list.isEmpty()); + assertEquals(0, fake.invocations); + } + + @Test + public void testSelectListLimitN_negative() { + FakeMapper fake = new FakeMapper(List.of("a")); + + // 调用,并断言:分页插件会把负数的 size 视为不分页,因此直接拒绝 + assertThrows(IllegalArgumentException.class, + () -> fake.mapper.selectListLimitN(new QueryWrapper().orderByDesc("id"), -1)); + assertEquals(0, fake.invocations); + } + + @Test + public void testSelectFirstOne() { + FakeMapper fake = new FakeMapper(List.of("a", "b")); + QueryWrapper wrapper = new QueryWrapper().eq("status", 1).orderByAsc("id"); + + // 调用,并断言:只取 1 条,返回第 1 条 + assertEquals("a", fake.mapper.selectFirstOne(wrapper)); + assertEquals(1, fake.invocations); + assertEquals(1, fake.page.getCurrent()); + assertEquals(1, fake.page.getSize()); + assertFalse(fake.page.searchCount()); + assertSame(wrapper, fake.wrapper); + } + + @Test + public void testSelectFirstOne_empty() { + FakeMapper fake = new FakeMapper(Collections.emptyList()); + + // 调用,并断言:不存在时返回 null + assertNull(fake.mapper.selectFirstOne(new QueryWrapper().orderByAsc("id"))); + assertEquals(1, fake.invocations); + } + + /** + * 分页插件按执行连接识别的数据库类型生成的分页语句(第 1 页的 offset 为 0),均可被 JSqlParser 解析 + * + * 注意:SQL Server 的 OFFSET FETCH 要求原语句有 ORDER BY,因此调用方必须设置排序 + */ + @Test + public void testPaginationDialect() throws Exception { + String sql = "SELECT id, name FROM system_users WHERE status = ? ORDER BY id DESC"; + + // MySQL、OSCAR 神通等:LIMIT + assertEquals(sql + " LIMIT ?", paginate(DbType.MYSQL, sql)); + assertEquals(sql + " LIMIT ?", paginate(DbType.OSCAR, sql)); + // PostgreSQL、H2、人大金仓、openGauss、瀚高等:LIMIT + assertEquals(sql + " LIMIT ?", paginate(DbType.POSTGRE_SQL, sql)); + assertEquals(sql + " LIMIT ?", paginate(DbType.H2, sql)); + assertEquals(sql + " LIMIT ?", paginate(DbType.KINGBASE_ES, sql)); + assertEquals(sql + " LIMIT ?", paginate(DbType.OPENGAUSS, sql)); + assertEquals(sql + " LIMIT ?", paginate(DbType.HIGH_GO, sql)); + // Oracle、达梦:ROWNUM 包裹原语句,先排序后取行,兼容 Oracle 11g + String oracle = "SELECT * FROM ( SELECT TMP.*, ROWNUM ROW_ID FROM ( " + sql + + " ) TMP WHERE ROWNUM <=?) WHERE ROW_ID > ?"; + assertEquals(oracle, paginate(DbType.ORACLE, sql)); + assertEquals(oracle, paginate(DbType.DM, sql)); + // SQL Server 2012+:OFFSET FETCH 直接追加在 ORDER BY 之后 + assertEquals(sql + " OFFSET ? ROWS FETCH NEXT ? ROWS ONLY", paginate(DbType.SQL_SERVER, sql)); + + for (DbType dbType : List.of(DbType.MYSQL, DbType.POSTGRE_SQL, DbType.ORACLE, DbType.SQL_SERVER)) { + assertNotNull(CCJSqlParserUtil.parse(paginate(dbType, sql)), dbType.name()); + } + } + + private static String paginate(DbType dbType, String sql) { + return DialectFactory.getDialect(dbType).buildPaginationSql(sql, 0, 5).getDialectSql(); + } + + /** + * 基于 JDK 动态代理的 {@link BaseMapperX}:default 方法真实执行,selectList(IPage, Wrapper) 记录参数并返回固定结果 + * + * @author 芋道源码 + */ + private static class FakeMapper implements InvocationHandler { + + private final List records; + private final BaseMapperX mapper; + private IPage page; + private Wrapper wrapper; + private int invocations; + + @SuppressWarnings("unchecked") + FakeMapper(List records) { + this.records = records; + this.mapper = (BaseMapperX) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[]{BaseMapperX.class}, this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (method.isDefault()) { + return InvocationHandler.invokeDefault(proxy, method, args); + } + if ("selectList".equals(method.getName()) && args != null && args.length == 2 + && args[0] instanceof IPage && args[1] instanceof Wrapper) { + invocations++; + page = (IPage) args[0]; + wrapper = (Wrapper) args[1]; + return records; + } + throw new UnsupportedOperationException(method.getName()); + } + + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java new file mode 100644 index 0000000..8f25f55 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java @@ -0,0 +1,242 @@ +package cn.iocoder.yudao.framework.mybatis.core.util; + +import cn.hutool.core.exceptions.UtilException; +import cn.hutool.extra.spring.SpringUtil; +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder; +import com.baomidou.mybatisplus.annotation.DbType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.jdbc.datasource.ConnectionHolder; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import javax.sql.DataSource; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link JdbcUtils} 的单元测试 + * + * @author 芋道源码 + */ +public class JdbcUtilsTest { + + private ConfigurableListableBeanFactory originBeanFactory; + + @BeforeEach + public void setUp() { + try { + originBeanFactory = SpringUtil.getConfigurableBeanFactory(); + } catch (UtilException e) { + originBeanFactory = null; + } + } + + @AfterEach + public void tearDown() { + DynamicDataSourceContextHolder.clear(); + new SpringUtil().postProcessBeanFactory(originBeanFactory); + } + + @Test + public void testGetDbType_cachedPerDataSource() { + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a"); + FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433"); + + for (int i = 0; i < 3; i++) { + assertEquals(DbType.MYSQL, JdbcUtils.getDbType(mysql.getDataSource())); + assertEquals(DbType.SQL_SERVER, JdbcUtils.getDbType(sqlServer.getDataSource())); + } + assertEquals(1, mysql.getConnectionCount()); + assertEquals(1, sqlServer.getConnectionCount()); + } + + @Test + public void testGetDbType_followsDynamicRouting() { + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a"); + FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe"); + DynamicRoutingDataSource dynamicRoutingDataSource = new DynamicRoutingDataSource(Collections.emptyList()); + dynamicRoutingDataSource.setPrimary("master"); + dynamicRoutingDataSource.addDataSource("master", postgresql.getDataSource()); + dynamicRoutingDataSource.addDataSource("oracle", oracle.getDataSource()); + mockBeanFactory("dynamicRoutingDataSource", dynamicRoutingDataSource); + + for (int i = 0; i < 3; i++) { + assertSame(postgresql.getDataSource(), JdbcUtils.getDataSource()); + assertEquals(DbType.POSTGRE_SQL, JdbcUtils.getDbType()); + DynamicDataSourceContextHolder.push("oracle"); + assertSame(oracle.getDataSource(), JdbcUtils.getDataSource()); + assertEquals(DbType.ORACLE, JdbcUtils.getDbType()); + DynamicDataSourceContextHolder.poll(); + } + assertEquals(1, postgresql.getConnectionCount()); + assertEquals(1, oracle.getConnectionCount()); + } + + @Test + public void testGetDbType_withoutDynamicRoutingDataSource() { + FakeDataSource h2 = new FakeDataSource("H2", "jdbc:h2:mem:testdb;MODE=MYSQL"); + mockBeanFactory("dataSource", h2.getDataSource()); + + assertSame(h2.getDataSource(), JdbcUtils.getDataSource()); + assertEquals(DbType.H2, JdbcUtils.getDbType()); + } + + @Test + public void testGetDbType_transactionConnectionDoesNotBorrowOrClose() throws SQLException { + FakeDataSource h2 = new FakeDataSource("H2", "jdbc:h2:mem:single_connection"); + mockBeanFactory("dataSource", h2.getDataSource()); + Connection held = h2.getDataSource().getConnection(); + h2.setFailOnConnection(true); + TransactionSynchronizationManager.bindResource(h2.getDataSource(), new ConnectionHolder(held)); + try { + assertEquals(DbType.H2, JdbcUtils.getDbType()); + assertEquals(1, h2.getConnectionCount()); + assertEquals(0, h2.closeCount); + } finally { + TransactionSynchronizationManager.unbindResource(h2.getDataSource()); + held.close(); + } + } + + @Test + public void testGetDbType_transactionUsesBoundConnectionWithoutPoisoningRouteCache() throws SQLException { + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://probe/transaction"); + FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@probe"); + DynamicRoutingDataSource routing = new DynamicRoutingDataSource(Collections.emptyList()); + routing.setPrimary("master"); + routing.addDataSource("master", postgresql.getDataSource()); + routing.addDataSource("oracle", oracle.getDataSource()); + mockBeanFactory("dynamicRoutingDataSource", routing); + Connection held = postgresql.getDataSource().getConnection(); + TransactionSynchronizationManager.bindResource(routing, new ConnectionHolder(held)); + DynamicDataSourceContextHolder.push("oracle"); + try { + assertEquals(DbType.POSTGRE_SQL, JdbcUtils.getDbType()); + assertEquals(1, postgresql.getConnectionCount()); + assertEquals(0, oracle.getConnectionCount()); + assertEquals(0, postgresql.closeCount); + } finally { + TransactionSynchronizationManager.unbindResource(routing); + held.close(); + } + assertEquals(DbType.ORACLE, JdbcUtils.getDbType()); + assertEquals(1, oracle.getConnectionCount()); + } + + @Test + public void testGetDbType_unregisteredProductName() { + // 产品名未登记时,按明确的原生驱动 URL 识别,与 getDbType(url) 的归类一致 + assertEquals(DbType.POSTGRE_SQL, JdbcUtils.getDbType( + new FakeDataSource("HighGo", "jdbc:highgo://127.0.0.1:5866/highgo").getDataSource())); + assertEquals(DbType.MARIADB, JdbcUtils.getDbType( + new FakeDataSource("MariaDB", "jdbc:mariadb://127.0.0.1:3306/a").getDataSource())); + + // 完全未识别的产品:明确异常,而不是 NPE;异常信息不包含 URL + FakeDataSource sqlite = new FakeDataSource("SQLite", "jdbc:sqlite:/tmp/test.db?user=root&password=secret"); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> JdbcUtils.getDbType(sqlite.getDataSource())); + assertTrue(ex.getMessage().contains("SQLite")); + assertFalse(ex.getMessage().contains("secret")); + } + + @Test + public void testGetDbType_blankProductName() { + assertNull(JdbcUtils.getDbType(new FakeDataSource("", "jdbc:mysql://127.0.0.1:3306/a").getDataSource())); + } + + @Test + public void testGetDbType_failureNotCached() { + FakeDataSource broken = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a"); + broken.setFailOnConnection(true); + assertThrows(IllegalArgumentException.class, () -> JdbcUtils.getDbType(broken.getDataSource())); + assertThrows(IllegalArgumentException.class, () -> JdbcUtils.getDbType(broken.getDataSource())); + assertEquals(2, broken.getConnectionCount()); + + broken.setFailOnConnection(false); + assertEquals(DbType.MYSQL, JdbcUtils.getDbType(broken.getDataSource())); + } + + private static void mockBeanFactory(String beanName, Object bean) { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.registerSingleton(beanName, bean); + new SpringUtil().postProcessBeanFactory(beanFactory); + } + + /** + * 基于 JDK 动态代理的数据源,只提供识别 DB 类型所需的元数据,并记录获取连接的次数 + */ + private static class FakeDataSource implements InvocationHandler { + + private final String productName; + private final String url; + private final DataSource dataSource; + private int connectionCount; + private int closeCount; + private boolean failOnConnection; + + FakeDataSource(String productName, String url) { + this.productName = productName; + this.url = url; + this.dataSource = proxy(DataSource.class); + } + + DataSource getDataSource() { + return dataSource; + } + + int getConnectionCount() { + return connectionCount; + } + + void setFailOnConnection(boolean failOnConnection) { + this.failOnConnection = failOnConnection; + } + + @SuppressWarnings("unchecked") + private T proxy(Class type) { + return (T) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{type}, this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + switch (method.getName()) { + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + case "toString": + return "FakeDataSource(" + productName + ")"; + case "getConnection": + connectionCount++; + if (failOnConnection) { + throw new SQLException("connection refused"); + } + return proxy(Connection.class); + case "getMetaData": + return proxy(DatabaseMetaData.class); + case "close": + closeCount++; + return null; + case "getDatabaseProductName": + return productName; + case "getURL": + return url; + default: + throw new UnsupportedOperationException(method.getName()); + } + } + + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtilsTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtilsTest.java index 898cb45..fdf07e6 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtilsTest.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtilsTest.java @@ -18,12 +18,57 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +// TODO DONE @AI:参考 system user test 的风格;统一准备参数、调用、断言分区。 /** * {@link MyBatisUtils} 的单元测试 */ public class MyBatisUtilsTest { @Test + public void testBuildPage_offset() { + // 准备参数 + long offset = 3; + long size = 10; + + // 调用 + Page page = MyBatisUtils.buildPage(offset, size, false); + + // 断言 + assertEquals(3L, page.offset()); + assertEquals(10L, page.getSize()); + assertFalse(page.searchCount()); + } + + @Test + public void testBuildPage_negativeOffset() { + // 准备参数 + long offset = -1; + long size = 10; + + // 调用 + Page page = MyBatisUtils.buildPage(offset, size, true); + + // 断言 + assertEquals(0L, page.offset()); + assertEquals(10L, page.getSize()); + assertTrue(page.searchCount()); + assertEquals(0L, MyBatisUtils.buildPage(0, 10, false).offset()); + } + + @Test + public void testBuildPage_largeOffset() { + // 准备参数 + long offset = (long) Integer.MAX_VALUE + 3; + + // 调用 + Page page = MyBatisUtils.buildPage(offset, 10, false); + + // 断言 + assertEquals(offset, page.offset()); + assertEquals(Long.MAX_VALUE, MyBatisUtils.buildPage(Long.MAX_VALUE, 10, false).offset()); + } + + @Test public void testBuildPage_sortingFields() { // 准备参数 PageParam pageParam = new PageParam(); @@ -94,15 +139,28 @@ public class MyBatisUtilsTest { @Test public void testOrderDirection() { - assertTrue(MyBatisUtils.isAscOrder(SortingField.ORDER_ASC)); - assertFalse(MyBatisUtils.isAscOrder(SortingField.ORDER_DESC)); - assertEquals("ASC", MyBatisUtils.getOrderDirection(SortingField.ORDER_ASC)); - assertEquals("DESC", MyBatisUtils.getOrderDirection(SortingField.ORDER_DESC)); - assertEquals("DESC", MyBatisUtils.getOrderDirection(null)); + // 准备参数 + String ascOrder = SortingField.ORDER_ASC; + String descOrder = SortingField.ORDER_DESC; + + // 调用 + boolean asc = MyBatisUtils.isAscOrder(ascOrder); + boolean desc = MyBatisUtils.isAscOrder(descOrder); + String ascDirection = MyBatisUtils.getOrderDirection(ascOrder); + String descDirection = MyBatisUtils.getOrderDirection(descOrder); + String defaultDirection = MyBatisUtils.getOrderDirection(null); + + // 断言 + assertTrue(asc); + assertFalse(desc); + assertEquals("ASC", ascDirection); + assertEquals("DESC", descDirection); + assertEquals("DESC", defaultDirection); } @Test public void testFindInSet() { + // 调用,并断言各数据库模板保持原有参数序号和列名 assertEquals("FIND_IN_SET({0}, websites) <> 0", MyBatisUtils.findInSet(DbType.MYSQL, "websites", 0)); assertEquals("POSITION(',' || CAST({0} AS VARCHAR) || ',' IN ',' || websites || ',') > 0", @@ -121,6 +179,7 @@ public class MyBatisUtilsTest { @Test public void testFindInSet_invalidColumnName() { + // 调用,并断言异常 assertThrows(IllegalArgumentException.class, () -> MyBatisUtils.findInSet(DbType.MYSQL, "websites;drop table system_tenant", 0)); assertThrows(IllegalArgumentException.class, @@ -129,12 +188,14 @@ public class MyBatisUtilsTest { @Test public void testFindInSet_invalidParamIndex() { + // 调用,并断言异常 assertThrows(IllegalArgumentException.class, () -> MyBatisUtils.findInSet(DbType.MYSQL, "websites", -1)); } @Test public void testFindInSet_multipleValues() { + // 调用,并断言多个值使用独立参数序号;空集合拒绝生成条件 assertEquals("FIND_IN_SET({0}, tag_ids) <> 0 OR FIND_IN_SET({1}, tag_ids) <> 0", MyBatisUtils.findInSet(DbType.MYSQL, "tag_ids", Arrays.asList(1L, 2L))); assertThrows(IllegalArgumentException.class, diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java new file mode 100644 index 0000000..feebc04 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java @@ -0,0 +1,720 @@ +package cn.iocoder.yudao.framework.mybatis.core.util; + +import cn.hutool.core.exceptions.UtilException; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils.Dialect; +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.ParameterMapping; +import org.apache.ibatis.mapping.SqlSource; +import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.jdbc.datasource.ConnectionHolder; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import javax.sql.DataSource; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link SqlDialectUtils} 的单元测试 + * + * @author 芋道源码 + */ +public class SqlDialectUtilsTest { + + private static final String P = "${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@"; + + private ConfigurableListableBeanFactory originBeanFactory; + + @BeforeEach + public void setUp() { + try { + originBeanFactory = SpringUtil.getConfigurableBeanFactory(); + } catch (UtilException e) { + originBeanFactory = null; + } + } + + @AfterEach + public void tearDown() { + DynamicDataSourceContextHolder.clear(); + new SpringUtil().postProcessBeanFactory(originBeanFactory); + } + + // ========== SQL 片段 ========== + + @Test + public void testFormatDate() { + assertEquals("DATE_FORMAT(create_time, '%Y-%m-%d')", SqlDialectUtils.formatDate(Dialect.ORIGINAL, "create_time")); + assertEquals("TO_CHAR(t.create_time, 'YYYY-MM-DD')", SqlDialectUtils.formatDate(Dialect.POSTGRESQL, "t.create_time")); + assertEquals("TO_CHAR(create_time, 'YYYY-MM-DD')", SqlDialectUtils.formatDate(Dialect.ORACLE, "create_time")); + assertEquals("CONVERT(varchar(10), create_time, 23)", SqlDialectUtils.formatDate(Dialect.SQLSERVER, "create_time")); + } + + @Test + public void testFormatYearMonth() { + assertEquals("DATE_FORMAT(order_date, '%Y%m')", SqlDialectUtils.formatYearMonth(Dialect.ORIGINAL, "order_date")); + assertEquals("TO_CHAR(order_date, 'YYYYMM')", SqlDialectUtils.formatYearMonth(Dialect.POSTGRESQL, "order_date")); + assertEquals("TO_CHAR(order_date, 'YYYYMM')", SqlDialectUtils.formatYearMonth(Dialect.ORACLE, "order_date")); + assertEquals("CONVERT(varchar(6), order_date, 112)", SqlDialectUtils.formatYearMonth(Dialect.SQLSERVER, "order_date")); + } + + @Test + public void testFormatYearDashMonth() { + assertEquals("CONCAT(EXTRACT(YEAR FROM pay_time), '-', LPAD(CONCAT(EXTRACT(MONTH FROM pay_time)), 2, '0'))", + SqlDialectUtils.formatYearDashMonth(Dialect.ORIGINAL, "pay_time")); + assertEquals("TO_CHAR(pay_time, 'YYYY-MM')", SqlDialectUtils.formatYearDashMonth(Dialect.POSTGRESQL, "pay_time")); + assertEquals("TO_CHAR(pay_time, 'YYYY-MM')", SqlDialectUtils.formatYearDashMonth(Dialect.ORACLE, "pay_time")); + assertEquals("CONVERT(varchar(7), pay_time, 120)", SqlDialectUtils.formatYearDashMonth(Dialect.SQLSERVER, "pay_time")); + } + + @Test + public void testDate() { + assertEquals("DATE(send_time)", SqlDialectUtils.date(Dialect.ORIGINAL, "send_time")); + assertEquals("TO_CHAR(send_time, 'YYYY-MM-DD')", SqlDialectUtils.date(Dialect.POSTGRESQL, "send_time")); + assertEquals("TO_CHAR(send_time, 'YYYY-MM-DD')", SqlDialectUtils.date(Dialect.ORACLE, "send_time")); + assertEquals("CONVERT(varchar(10), send_time, 23)", SqlDialectUtils.date(Dialect.SQLSERVER, "send_time")); + } + + @Test + public void testCastDateText() { + assertEquals("CAST(create_time AS DATE)", SqlDialectUtils.castDateText(Dialect.ORIGINAL, "create_time")); + assertEquals("TO_CHAR(create_time, 'YYYY-MM-DD')", SqlDialectUtils.castDateText(Dialect.POSTGRESQL, "create_time")); + assertEquals("TO_CHAR(create_time, 'YYYY-MM-DD')", SqlDialectUtils.castDateText(Dialect.ORACLE, "create_time")); + assertEquals("CONVERT(varchar(10), create_time, 23)", SqlDialectUtils.castDateText(Dialect.SQLSERVER, "create_time")); + } + + @Test + public void testCastDate() { + assertEquals("CAST(create_time AS DATE)", SqlDialectUtils.castDate(Dialect.ORIGINAL, "create_time")); + assertEquals("CAST(create_time AS DATE)", SqlDialectUtils.castDate(Dialect.POSTGRESQL, "create_time")); + assertEquals("TRUNC(create_time)", SqlDialectUtils.castDate(Dialect.ORACLE, "create_time")); + assertEquals("CAST(create_time AS DATE)", SqlDialectUtils.castDate(Dialect.SQLSERVER, "create_time")); + } + + @Test + public void testMonth() { + assertEquals("MONTH(order_date)", SqlDialectUtils.month(Dialect.ORIGINAL, "order_date")); + assertEquals("CAST(EXTRACT(MONTH FROM order_date) AS INTEGER)", SqlDialectUtils.month(Dialect.POSTGRESQL, "order_date")); + assertEquals("EXTRACT(MONTH FROM order_date)", SqlDialectUtils.month(Dialect.ORACLE, "order_date")); + assertEquals("MONTH(order_date)", SqlDialectUtils.month(Dialect.SQLSERVER, "order_date")); + } + + @Test + public void testYear() { + assertEquals("YEAR(return_time)", SqlDialectUtils.year(Dialect.ORIGINAL, "return_time")); + assertEquals("CAST(EXTRACT(YEAR FROM return_time) AS INTEGER)", SqlDialectUtils.year(Dialect.POSTGRESQL, "return_time")); + assertEquals("EXTRACT(YEAR FROM return_time)", SqlDialectUtils.year(Dialect.ORACLE, "return_time")); + assertEquals("YEAR(return_time)", SqlDialectUtils.year(Dialect.SQLSERVER, "return_time")); + } + + @Test + public void testDayOfMonth() { + assertEquals("DAYOFMONTH(birthday)", SqlDialectUtils.dayOfMonth(Dialect.ORIGINAL, "birthday")); + assertEquals("CAST(EXTRACT(DAY FROM birthday) AS INTEGER)", SqlDialectUtils.dayOfMonth(Dialect.POSTGRESQL, "birthday")); + assertEquals("EXTRACT(DAY FROM birthday)", SqlDialectUtils.dayOfMonth(Dialect.ORACLE, "birthday")); + assertEquals("DAY(birthday)", SqlDialectUtils.dayOfMonth(Dialect.SQLSERVER, "birthday")); + } + + @Test + public void testCustomerDealCycle() { + String original = "COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, customer.create_time, contract.order_date)), 1), 0)"; + assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.ORIGINAL, "customer.create_time", "contract.order_date")); + // PostgreSQL:两个 TIMESTAMP 相减为 interval,EXTRACT(DAY) 即完整天数(向零截断、保留负号),TRUNC 截断保留 1 位 + assertEquals("COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(contract.order_date AS TIMESTAMP) - " + + "CAST(customer.create_time AS TIMESTAMP))) AS NUMERIC)), 1), 0)", + SqlDialectUtils.customerDealCycle(Dialect.POSTGRESQL, "customer.create_time", "contract.order_date")); + // Oracle、SQL Server:完整天数表达式依赖实际列类型,尚未验收,保持原有片段 + assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.ORACLE, "customer.create_time", "contract.order_date")); + assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.SQLSERVER, "customer.create_time", "contract.order_date")); + } + + @Test + public void testFromDual() { + assertEquals("", SqlDialectUtils.fromDual(Dialect.ORIGINAL)); + assertEquals("", SqlDialectUtils.fromDual(Dialect.POSTGRESQL)); + assertEquals("FROM DUAL", SqlDialectUtils.fromDual(Dialect.ORACLE)); + assertEquals("", SqlDialectUtils.fromDual(Dialect.SQLSERVER)); + } + + @Test + public void testInvalidColumnName() { + List> fragments = List.of(SqlDialectUtils::formatDate, + SqlDialectUtils::formatYearMonth, SqlDialectUtils::formatYearDashMonth, SqlDialectUtils::date, + SqlDialectUtils::castDateText, SqlDialectUtils::castDate, SqlDialectUtils::month, + SqlDialectUtils::year, SqlDialectUtils::dayOfMonth); + for (BiFunction fragment : fragments) { + for (Dialect dialect : Dialect.values()) { + assertThrows(IllegalArgumentException.class, () -> fragment.apply(dialect, "create_time) OR (1=1")); + assertThrows(IllegalArgumentException.class, () -> fragment.apply(dialect, "create_time;drop table t")); + assertThrows(IllegalArgumentException.class, () -> fragment.apply(dialect, "'2026-01-01'")); + assertThrows(IllegalArgumentException.class, () -> fragment.apply(dialect, "")); + assertThrows(IllegalArgumentException.class, () -> fragment.apply(dialect, null)); + } + } + for (Dialect dialect : Dialect.values()) { + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.customerDealCycle(dialect, "customer.create_time", "NOW()")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.customerDealCycle(dialect, "1) - (1", "contract.order_date")); + } + } + + /** + * MyBatis Plus 租户等插件会使用 JSqlParser 解析最终 SQL,所有方言的片段均需可被解析 + */ + @Test + public void testFragments_parsedByJSqlParser() throws Exception { + for (Dialect dialect : Dialect.values()) { + List expressions = List.of( + SqlDialectUtils.formatDate(dialect, "t.create_time"), + SqlDialectUtils.formatYearMonth(dialect, "t.create_time"), + SqlDialectUtils.formatYearDashMonth(dialect, "t.create_time"), + SqlDialectUtils.date(dialect, "t.create_time"), + SqlDialectUtils.castDateText(dialect, "t.create_time"), + SqlDialectUtils.castDate(dialect, "t.create_time"), + SqlDialectUtils.month(dialect, "t.create_time"), + SqlDialectUtils.year(dialect, "t.create_time"), + SqlDialectUtils.dayOfMonth(dialect, "t.create_time")); + for (String expression : expressions) { + String sql = "SELECT " + expression + " AS statDate, COUNT(1) AS total FROM crm_customer t" + + " WHERE t.create_time >= ? GROUP BY " + expression + " ORDER BY statDate"; + assertNotNull(CCJSqlParserUtil.parse(sql), dialect + " " + expression); + } + String dealCycleSql = "SELECT customer.owner_user_id, " + + SqlDialectUtils.customerDealCycle(dialect, "customer.create_time", "contract.order_date") + + " AS customer_deal_cycle FROM crm_customer customer" + + " LEFT JOIN crm_contract contract ON contract.customer_id = customer.id" + + " GROUP BY customer.owner_user_id"; + assertNotNull(CCJSqlParserUtil.parse(dealCycleSql), dialect + " customerDealCycle"); + String summarySql = "SELECT (SELECT COUNT(1) FROM ai1_agent) AS agentCount, " + + "(SELECT COUNT(1) FROM ai1_model) AS modelCount " + SqlDialectUtils.fromDual(dialect); + assertNotNull(CCJSqlParserUtil.parse(summarySql), dialect + " fromDual"); + } + } + + // ========== 行数限制 ========== + + @Test + public void testLimitRows() { + assertEquals("LIMIT 1", SqlDialectUtils.limitRows(Dialect.ORIGINAL, 1, false)); + assertEquals("LIMIT 10", SqlDialectUtils.limitRows(Dialect.POSTGRESQL, 10, true)); + assertEquals("FETCH FIRST 1 ROWS ONLY", SqlDialectUtils.limitRows(Dialect.ORACLE, 1, false)); + assertEquals("FETCH FIRST 10 ROWS ONLY", SqlDialectUtils.limitRows(Dialect.ORACLE, 10, true)); + // SQL Server:OFFSET 必须跟在 ORDER BY 之后,无排序时补充常量排序 + assertEquals("OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY", SqlDialectUtils.limitRows(Dialect.SQLSERVER, 10, true)); + assertEquals("ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY", + SqlDialectUtils.limitRows(Dialect.SQLSERVER, 1, false)); + } + + @Test + public void testLimitRows_zeroAndNegative() { + assertEquals("LIMIT 0", SqlDialectUtils.limitRows(Dialect.ORIGINAL, 0, true)); + assertEquals("LIMIT 0", SqlDialectUtils.limitRows(Dialect.POSTGRESQL, 0, true)); + assertEquals("FETCH FIRST 0 ROWS ONLY", SqlDialectUtils.limitRows(Dialect.ORACLE, 0, true)); + // SQL Server 的 FETCH 不支持 0 行 + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.limitRows(Dialect.SQLSERVER, 0, true)); + for (Dialect dialect : Dialect.values()) { + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.limitRows(dialect, -1, true)); + } + } + + @Test + public void testLimitParam() { + assertEquals("LIMIT #{limit}", SqlDialectUtils.limitParam(Dialect.ORIGINAL, "limit", true)); + assertEquals("LIMIT #{reqVO.limit}", SqlDialectUtils.limitParam(Dialect.POSTGRESQL, "reqVO.limit", true)); + assertEquals("FETCH FIRST #{limit} ROWS ONLY", SqlDialectUtils.limitParam(Dialect.ORACLE, "limit", false)); + assertEquals("OFFSET 0 ROWS FETCH NEXT #{limit} ROWS ONLY", SqlDialectUtils.limitParam(Dialect.SQLSERVER, "limit", true)); + assertEquals("ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT #{limit} ROWS ONLY", + SqlDialectUtils.limitParam(Dialect.SQLSERVER, "limit", false)); + // 参数名只能是标识符或属性路径 + for (Dialect dialect : Dialect.values()) { + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.limitParam(dialect, "limit} OR 1=1 #{a", true)); + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.limitParam(dialect, "1", true)); + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.limitParam(dialect, "", true)); + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.limitParam(dialect, null, true)); + } + } + + // ========== 分批删除 ========== + + @Test + public void testDeleteBatchByLessThan() { + // 原有语句保持不变 + assertEquals("DELETE FROM infra_job_log WHERE create_time < #{createTime} LIMIT #{limit}", + SqlDialectUtils.deleteBatchByLessThan(Dialect.ORIGINAL, "infra_job_log", "create_time", "createTime", "limit")); + assertEquals("DELETE FROM infra_job_log WHERE id IN (SELECT id FROM infra_job_log" + + " WHERE create_time < #{createTime} LIMIT #{limit})", + SqlDialectUtils.deleteBatchByLessThan(Dialect.POSTGRESQL, "infra_job_log", "create_time", "createTime", "limit")); + assertEquals("DELETE FROM system_oauth2_access_token WHERE expires_time < #{expiresTime} AND ROWNUM <= #{limit}", + SqlDialectUtils.deleteBatchByLessThan(Dialect.ORACLE, "system_oauth2_access_token", "expires_time", "expiresTime", "limit")); + assertEquals("DELETE FROM system_oauth2_access_token WHERE id IN (SELECT TOP (#{limit}) id FROM system_oauth2_access_token" + + " WHERE expires_time < #{expiresTime})", + SqlDialectUtils.deleteBatchByLessThan(Dialect.SQLSERVER, "system_oauth2_access_token", "expires_time", "expiresTime", "limit")); + } + + @Test + public void testDeleteBatchByLessThan_invalidName() { + for (Dialect dialect : Dialect.values()) { + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.deleteBatchByLessThan(dialect, + "infra_job_log; DROP TABLE t", "create_time", "createTime", "limit")); + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.deleteBatchByLessThan(dialect, + "infra_job_log", "create_time) OR (1=1", "createTime", "limit")); + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.deleteBatchByLessThan(dialect, + "infra_job_log", "create_time", "createTime} OR 1=1 OR #{a", "limit")); + assertThrows(IllegalArgumentException.class, () -> SqlDialectUtils.deleteBatchByLessThan(dialect, + "infra_job_log", "create_time", "createTime", null)); + } + } + + /** + * MyBatis Plus 租户、数据权限插件会使用 JSqlParser 解析最终 SQL,所有方言的语句均需可被解析 + * + * 注意:JSqlParser 5.2 不支持 DELETE TOP (?),因此 SQL Server 使用主键子查询 + */ + @Test + public void testLimitAndDelete_parsedByJSqlParser() throws Exception { + for (Dialect dialect : Dialect.values()) { + String deleteSql = SqlDialectUtils.deleteBatchByLessThan(dialect, "infra_job_log", "create_time", "createTime", "limit"); + assertNotNull(CCJSqlParserUtil.parse(bind(deleteSql)), dialect + " " + deleteSql); + + String orderedSql = "SELECT user_id AS userId, COUNT(*) AS messageCount FROM im_private_message" + + " WHERE send_time >= ? GROUP BY user_id ORDER BY messageCount DESC " + + SqlDialectUtils.limitParam(dialect, "limit", true); + assertNotNull(CCJSqlParserUtil.parse(bind(orderedSql)), dialect + " " + orderedSql); + String unorderedSql = "SELECT * FROM hrm_salary_option WHERE code = ? " + + SqlDialectUtils.limitRows(dialect, 1, false); + assertNotNull(CCJSqlParserUtil.parse(unorderedSql), dialect + " " + unorderedSql); + } + } + + // ========== 方言识别 ========== + + @Test + public void testResolveDialect_accepted() throws SQLException { + assertEquals(Dialect.POSTGRESQL, resolve("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/ruoyi-vue-pro", 17)); + assertEquals(Dialect.POSTGRESQL, resolve("PostgreSQL", "jdbc:p6spy:postgresql://127.0.0.1:5432/ruoyi-vue-pro", 17)); + assertEquals(Dialect.ORACLE, resolve("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21)); + assertEquals(Dialect.SQLSERVER, resolve("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16)); + assertEquals(Dialect.SQLSERVER, resolve("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 11)); + } + + @Test + public void testResolveDialect_original() throws SQLException { + // MySQL 族 + assertEquals(Dialect.ORIGINAL, resolve("MySQL", "jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro", 8)); + assertEquals(Dialect.ORIGINAL, resolve("MariaDB", "jdbc:mariadb://127.0.0.1:3306/ruoyi-vue-pro", 11)); + assertEquals(Dialect.ORIGINAL, resolve("OceanBase", "jdbc:oceanbase://127.0.0.1:2881/ruoyi-vue-pro", 4)); + // 已有旧路径:H2、达梦、神通、人大金仓 + assertEquals(Dialect.ORIGINAL, resolve("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2)); + assertEquals(Dialect.ORIGINAL, resolve("DM DBMS", "jdbc:dm://127.0.0.1:5236", 8)); + assertEquals(Dialect.ORIGINAL, resolve("OSCAR", "jdbc:oscar://127.0.0.1:2003/osrdb", 7)); + assertEquals(Dialect.ORIGINAL, resolve("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8)); + // 未验收的 PostgreSQL 变种:原生产品名 + assertEquals(Dialect.ORIGINAL, resolve("openGauss", "jdbc:opengauss://127.0.0.1:5432/test", 5)); + assertEquals(Dialect.ORIGINAL, resolve("GaussDB", "jdbc:gaussdb://127.0.0.1:8000/test", 5)); + // 未验收的 PostgreSQL 变种:产品名未登记或与 PostgreSQL 相同,按原生驱动 URL 识别 + assertEquals(Dialect.ORIGINAL, resolve("HighGo", "jdbc:highgo://127.0.0.1:5866/highgo", 6)); + assertEquals(Dialect.ORIGINAL, resolve("PostgreSQL", "jdbc:highgo://127.0.0.1:5866/highgo", 6)); + assertEquals(Dialect.ORIGINAL, resolve("PostgreSQL", "jdbc:opengauss://127.0.0.1:5432/test", 9)); + assertEquals(Dialect.ORIGINAL, resolve("PostgreSQL", "jdbc:mogdb://127.0.0.1:5432/test", 9)); + assertEquals(Dialect.ORIGINAL, resolve("PostgreSQL", "jdbc:gaussdb://127.0.0.1:8000/test", 9)); + assertEquals(Dialect.ORIGINAL, resolve("PostgreSQL", "jdbc:kingbase8://127.0.0.1:54321/test", 12)); + // 未验收的 SQL Server 版本 + assertEquals(Dialect.ORIGINAL, resolve("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 10)); + assertEquals(Dialect.ORIGINAL, resolve("Microsoft SQL Server 2005", "jdbc:jtds:sqlserver://127.0.0.1:1433", 9)); + } + + @Test + public void testResolveDialect_unsupported() { + String secretUrl = "jdbc:sqlite:/tmp/test.db?user=root&password=secret"; + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolve("SQLite", secretUrl, 3)); + assertTrue(ex.getMessage().contains("SQLite")); + assertFalse(ex.getMessage().contains("secret")); + assertFalse(ex.getMessage().contains("jdbc:")); + // 产品名为 PostgreSQL,但 URL 不是 PostgreSQL 或已识别的变种 + assertThrows(IllegalStateException.class, () -> resolve("PostgreSQL", "jdbc:vastbase://127.0.0.1:5432/test", 9)); + // 产品名为空 + assertThrows(IllegalStateException.class, () -> resolve(null, "jdbc:unknown://127.0.0.1/test", 1)); + } + + @Test + public void testGetDialect_cachedPerDataSource() { + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17); + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8); + + assertEquals(Dialect.POSTGRESQL, SqlDialectUtils.getDialect(postgresql.getDataSource())); + assertEquals(Dialect.ORIGINAL, SqlDialectUtils.getDialect(mysql.getDataSource())); + assertEquals(Dialect.POSTGRESQL, SqlDialectUtils.getDialect(postgresql.getDataSource())); + assertEquals(Dialect.ORIGINAL, SqlDialectUtils.getDialect(mysql.getDataSource())); + assertEquals(1, postgresql.getConnectionCount()); + assertEquals(1, mysql.getConnectionCount()); + } + + @Test + public void testGetDialect_failureNotCached() { + FakeDataSource broken = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17); + broken.setFailOnConnection(true); + assertThrows(IllegalStateException.class, () -> SqlDialectUtils.getDialect(broken.getDataSource())); + assertThrows(IllegalStateException.class, () -> SqlDialectUtils.getDialect(broken.getDataSource())); + assertEquals(2, broken.getConnectionCount()); + + // 恢复后可以正常识别 + broken.setFailOnConnection(false); + assertEquals(Dialect.POSTGRESQL, SqlDialectUtils.getDialect(broken.getDataSource())); + } + + @Test + public void testFormatDate_transactionConnectionDoesNotBorrowOrClose() throws SQLException { + FakeDataSource h2 = new FakeDataSource("H2", "jdbc:h2:mem:single_connection", 2); + Connection held = h2.getDataSource().getConnection(); + h2.setFailOnConnection(true); // 当前事务已占用唯一连接,任何额外借用都会失败 + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.registerSingleton("dataSource", h2.getDataSource()); + new SpringUtil().postProcessBeanFactory(beanFactory); + TransactionSynchronizationManager.bindResource(h2.getDataSource(), new ConnectionHolder(held)); + try { + assertEquals("DATE_FORMAT(create_time, '%Y-%m-%d')", SqlDialectUtils.formatDate("create_time")); + assertEquals(1, h2.getConnectionCount()); + assertEquals(0, h2.closeCount); + } finally { + TransactionSynchronizationManager.unbindResource(h2.getDataSource()); + held.close(); + } + } + + @Test + public void testGetDialect_transactionUsesBoundConnectionWithoutPoisoningRouteCache() throws SQLException { + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://probe/transaction", 17); + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://probe/other", 8); + mockDynamicRoutingDataSource(Map.of("master", postgresql, "mysql", mysql)); + DynamicRoutingDataSource routing = SpringUtil.getBean(DynamicRoutingDataSource.class); + Connection held = postgresql.getDataSource().getConnection(); + TransactionSynchronizationManager.bindResource(routing, new ConnectionHolder(held)); + DynamicDataSourceContextHolder.push("mysql"); + try { + // Spring 事务的执行连接仍为 PG,切换线程路由键不会改变它 + assertEquals("CAST(EXTRACT(MONTH FROM birthday) AS INTEGER)", SqlDialectUtils.month("birthday")); + assertEquals(1, postgresql.getConnectionCount()); + assertEquals(0, mysql.getConnectionCount()); + assertEquals(0, postgresql.closeCount); + } finally { + TransactionSynchronizationManager.unbindResource(routing); + held.close(); + } + // 事务结束后该路由真正使用 MySQL,不能命中错误的 PG 缓存 + assertEquals("MONTH(birthday)", SqlDialectUtils.month("birthday")); + assertEquals(1, mysql.getConnectionCount()); + } + + @Test + public void testFormatDate_followsDynamicRouting() { + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17); + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8); + FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21); + mockDynamicRoutingDataSource(Map.of("master", postgresql, "mysql", mysql, "oracle", oracle)); + + for (int i = 0; i < 3; i++) { + // 未切换时为主库 + assertEquals("TO_CHAR(create_time, 'YYYY-MM-DD')", SqlDialectUtils.formatDate("create_time")); + DynamicDataSourceContextHolder.push("mysql"); + assertEquals("DATE_FORMAT(create_time, '%Y-%m-%d')", SqlDialectUtils.formatDate("create_time")); + DynamicDataSourceContextHolder.push("oracle"); + assertEquals("TO_CHAR(create_time, 'YYYY-MM-DD')", SqlDialectUtils.formatDate("create_time")); + assertEquals("FROM DUAL", SqlDialectUtils.fromDual()); + DynamicDataSourceContextHolder.poll(); + assertEquals("", SqlDialectUtils.fromDual()); + DynamicDataSourceContextHolder.poll(); + } + assertEquals(1, postgresql.getConnectionCount()); + assertEquals(1, mysql.getConnectionCount()); + assertEquals(1, oracle.getConnectionCount()); + } + + @Test + public void testBoundSql_xmlScript() { + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17); + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8); + mockDynamicRoutingDataSource(Map.of("master", postgresql, "mysql", mysql)); + SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(), + "", Map.class); + Map params = new HashMap<>(); + params.put("beginTime", LocalDateTime.of(2026, 1, 1, 0, 0)); + params.put("endTime", LocalDateTime.of(2026, 1, 31, 23, 59, 59)); + + // 主库:PostgreSQL + BoundSql boundSql = sqlSource.getBoundSql(params); + assertEquals("SELECT TO_CHAR(create_time, 'YYYY-MM-DD') AS time, COUNT(*) AS customerCreateCount" + + " FROM crm_customer WHERE create_time BETWEEN ? AND ?" + + " GROUP BY TO_CHAR(create_time, 'YYYY-MM-DD')", normalize(boundSql.getSql())); + assertParameters(boundSql, "beginTime", "endTime"); + + // 同一个 SqlSource 在下一次执行时,跟随路由切换到 MySQL + DynamicDataSourceContextHolder.push("mysql"); + boundSql = sqlSource.getBoundSql(params); + assertEquals("SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS time, COUNT(*) AS customerCreateCount" + + " FROM crm_customer WHERE create_time BETWEEN ? AND ?" + + " GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d')", normalize(boundSql.getSql())); + assertParameters(boundSql, "beginTime", "endTime"); + } + + @Test + public void testBoundSql_annotation() { + FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16); + FakeDataSource h2 = new FakeDataSource("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2); + mockDynamicRoutingDataSource(Map.of("master", sqlServer, "h2", h2)); + // 与 @Select 注解相同:不使用