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
index d9f2d83..bf0dfd5 100644
--- 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
@@ -18,7 +18,7 @@ import java.util.regex.Pattern;
// TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。
// TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。
/**
- * 日期统计、逗号分隔列表关联、行数限制、分批删除 SQL 片段的方言工具类
+ * 日期统计、逗号分隔列表关联、JSON 数组匹配、行数限制、分批删除 SQL 片段的方言工具类
*
* 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库
* 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、
@@ -451,6 +451,41 @@ public class SqlDialectUtils {
}
}
+ // ========== JSON 数组 ==========
+
+ /**
+ * JSON 数组列是否包含 {0} 绑定的数值 ID,供 Wrapper 的 apply 使用,ID 必须作为 apply 的参数绑定
+ *
+ * 1. 原有片段:wrapArray 为 false 时为 JSON_CONTAINS(column, {0}),参数为 ID 的数字文本;
+ * 为 true 时为 JSON_CONTAINS(column, JSON_ARRAY({0})),参数为 Long
+ * 2. PostgreSQL:CAST(column AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT)),兼容 jsonb、json 与 JSON 文本列;
+ * 按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配
+ * 3. Oracle、SQL Server 及其它数据库保持原有片段,是否可用未经验收
+ *
+ * @param column JSON 数组列名,元素为数值 ID
+ * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数
+ * @return 包含 {0} 占位符的 SQL 条件片段
+ */
+ public static String jsonArrayContains(String column, boolean wrapArray) {
+ return jsonArrayContains(getDialect(), column, wrapArray);
+ }
+
+ /**
+ * 按指定方言生成 JSON 数组包含数值 ID 的条件;仅 PostgreSQL 输出新片段。
+ *
+ * @param dialect 已识别的数据库方言
+ * @param column 代码中固定的 JSON 数组列名
+ * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数
+ * @return 包含 {0} 占位符的 SQL 条件片段
+ */
+ static String jsonArrayContains(Dialect dialect, String column, boolean wrapArray) {
+ checkColumn(column);
+ if (dialect == Dialect.POSTGRESQL) {
+ return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))";
+ }
+ return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})";
+ }
+
// ========== 行数限制 ==========
/**
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
index 8453303..7f4a4ab 100644
--- 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
@@ -5,6 +5,7 @@ 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 com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.ParameterMapping;
@@ -27,6 +28,7 @@ import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -326,6 +328,104 @@ public class SqlDialectUtilsTest {
}
}
+ // ========== JSON 数组 ==========
+
+ @Test
+ public void testJsonArrayContains() {
+ // 原有片段、Oracle、SQL Server:保持原有 JSON_CONTAINS
+ for (Dialect dialect : List.of(Dialect.ORIGINAL, Dialect.ORACLE, Dialect.SQLSERVER)) {
+ assertEquals("JSON_CONTAINS(booking_user_ids, {0})",
+ SqlDialectUtils.jsonArrayContains(dialect, "booking_user_ids", false), dialect.name());
+ assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))",
+ SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true), dialect.name());
+ }
+ // PostgreSQL:两种原有片段统一为 jsonb 包含,参数按 BIGINT 匹配数字元素
+ assertEquals("CAST(booking_user_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))",
+ SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "booking_user_ids", false));
+ assertEquals("CAST(t.label_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))",
+ SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "t.label_ids", true));
+ }
+
+ @Test
+ public void testJsonArrayContains_invalidColumn() {
+ for (Dialect dialect : Dialect.values()) {
+ for (boolean wrapArray : new boolean[]{true, false}) {
+ assertThrows(IllegalArgumentException.class,
+ () -> SqlDialectUtils.jsonArrayContains(dialect, "label_ids, '[1]') OR (1=1", wrapArray));
+ assertThrows(IllegalArgumentException.class,
+ () -> SqlDialectUtils.jsonArrayContains(dialect, "'[1, 2]'", wrapArray));
+ assertThrows(IllegalArgumentException.class,
+ () -> SqlDialectUtils.jsonArrayContains(dialect, "label_ids;drop table t", wrapArray));
+ assertThrows(IllegalArgumentException.class,
+ () -> SqlDialectUtils.jsonArrayContains(dialect, "", wrapArray));
+ assertThrows(IllegalArgumentException.class,
+ () -> SqlDialectUtils.jsonArrayContains(dialect, null, wrapArray));
+ }
+ }
+ }
+
+ /**
+ * 与 PMS 工作项任一标签筛选相同的 OR 条件,所有方言均需可被 JSqlParser 解析
+ */
+ @Test
+ public void testJsonArrayContains_parsedByJSqlParser() throws Exception {
+ for (Dialect dialect : Dialect.values()) {
+ for (boolean wrapArray : new boolean[]{true, false}) {
+ String condition = SqlDialectUtils.jsonArrayContains(dialect, "label_ids", wrapArray).replace("{0}", "?");
+ String sql = "SELECT id FROM pms_work_item WHERE project_id = ? AND (" + condition + " OR " + condition + ")";
+ assertNotNull(CCJSqlParserUtil.parse(sql), dialect + " " + sql);
+ }
+ }
+ }
+
+ /**
+ * 与 Mapper 相同:通过 MyBatis Plus Wrapper 的 apply 绑定参数,再由 MyBatis 生成 BoundSql
+ */
+ @Test
+ public void testBoundSql_jsonArrayContains() throws Exception {
+ FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17);
+ FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21);
+ FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16);
+ FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8);
+ mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, "mysql", mysql));
+ SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(),
+ "", Map.class);
+ // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL
+ Map routes = new LinkedHashMap<>();
+ routes.put("master", Dialect.POSTGRESQL);
+ routes.put("oracle", Dialect.ORACLE);
+ routes.put("sqlserver", Dialect.SQLSERVER);
+ routes.put("mysql", Dialect.ORIGINAL);
+
+ for (Map.Entry route : routes.entrySet()) {
+ DynamicDataSourceContextHolder.push(route.getKey());
+ // PMS:多个标签按 OR 匹配任一标签,参数为 Long
+ String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true);
+ QueryWrapper