diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9136848 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.codeLens": true, + "java.test.editor.enableShortcuts": true, + "testing.gutterEnabled": true +} diff --git a/backend/src/main/java/com/yfd/platform/common/DynamicSQLMapper.java b/backend/src/main/java/com/yfd/platform/common/DynamicSQLMapper.java new file mode 100644 index 0000000..adefb25 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/common/DynamicSQLMapper.java @@ -0,0 +1,262 @@ +package com.yfd.platform.common; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 通用动态 SQL Mapper。 + *

+ * 说明: + *

+ * 1. 该接口通过注解 SQL 直接执行动态查询,适用于“表名/联表 SQL/查询列”需要运行期决定的场景;
+ * 2. 带 {@code QueryWrapper} 的方法统一使用 {@code ew.customSqlSegment} 追加条件;
+ * 3. 传入的 {@code sql}/{@code select}/{@code tableName} 属于动态片段,请在上层确保来源可信,避免注入风险。 + */ +@Mapper +public interface DynamicSQLMapper { + + /** + * 分页执行完整 SQL(SQL 内可使用 map 参数)。 + */ + @Select({""}) + List> pageAllList(Page page, + @Param("sql") String sql, + @Param("map") Map map); + + /** + * 分页执行完整 SQL(泛型结果,依赖 MyBatis 映射规则)。 + */ + @Select({""}) + List pageAllListWithResultType(Page page, + @Param("sql") String sql, + @Param("map") Map map, + @Param("resultType") Class resultType); + + /** + * 不分页执行完整 SQL。 + */ + @Select({""}) + List> getAllList(@Param("sql") String sql, + @Param("map") Map map); + + /** + * 不分页执行完整 SQL(泛型结果,依赖 MyBatis 映射规则)。 + */ + @Select({""}) + List getAllListWithResultType(@Param("sql") String sql, + @Param("map") Map map, + @Param("resultType") Class resultType); + + /** + * 统计条数(含动态条件)。 + */ + @Select({ + "" + }) + Integer count(@Param("sql") String sql, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 统计条数(无动态条件)。 + */ + @Select({""}) + Integer countNoWrapper(@Param("sql") String sql); + + /** + * 分页查询(含动态条件)。 + */ + @Select({ + "" + }) + List> pageList(Page page, + @Param("select") String select, + @Param("sql") String sql, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 分页查询(含动态条件,泛型结果)。 + */ + @Select({ + "" + }) + List pageListWithResultType(Page page, + @Param("select") String select, + @Param("sql") String sql, + @Param("ew") QueryWrapper queryWrapper, + @Param("resultType") Class resultType); + + /** + * 分页查询(条件可选;方法名保持兼容)。 + */ + @Select({ + "" + }) + List> pageNoFilterList(Page page, + @Param("select") String select, + @Param("sql") String sql, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 分页查询(无动态条件)。 + */ + @Select({""}) + List> pageListNoWrapper(Page page, + @Param("select") String select, + @Param("sql") String sql); + + /** + * 非分页查询(含动态条件)。 + */ + @Select({ + "" + }) + List> getList(@Param("select") String select, + @Param("sql") String sql, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 非分页查询(含动态条件,历史命名保留;返回 Map 结构)。 + */ + @Select({ + "" + }) + List> getListWithResultType(@Param("select") String select, + @Param("sql") String sql, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 单表条件查询(直接传 where 条件片段)。 + */ + @Select({""}) + List> getSingleTableList(@Param("select") String select, + @Param("table") String table, + @Param("condition") String condition); + + /** + * 非分页查询(条件可选;方法名保持兼容)。 + */ + @Select({ + "" + }) + List> getNoFilterList(@Param("select") String select, + @Param("sql") String sql, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 非分页查询(无动态条件)。 + */ + @Select({""}) + List> getListNoWrapper(@Param("select") String select, + @Param("sql") String sql); + + /** + * 汇总查询(含动态条件),返回单行汇总结果。 + */ + @Select({ + "" + }) + Map totalSummary(@Param("select") String select, + @Param("sql") String sql, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 汇总查询(无动态条件)。 + */ + @Select({""}) + Map totalSummaryNoWrapper(@Param("select") String select, + @Param("sql") String sql); + + /** + * 单表汇总查询(含动态条件)。 + */ + @Select({ + "" + }) + Map totalSummarySingleTable(@Param("select") String select, + @Param("tableName") String tableName, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 单表汇总查询(无动态条件)。 + */ + @Select({""}) + Map totalSummaryNoWrapperSingleTable(@Param("select") String select, + @Param("tableName") String tableName); + + /** + * 获取表中最大排序值。 + */ + @Select({""}) + Integer getOrderIndexMax(@Param("tableName") String tableName); + + /** + * 按条件获取表中最大排序值。 + */ + @Select({ + "" + }) + Integer getOrderIndexMaxByParentId(@Param("tableName") String tableName, + @Param("ew") QueryWrapper queryWrapper); + + /** + * 获取分组字段列表。 + */ + @Select({""}) + List getGroup(@Param("info") String info, @Param("joinsql") String joinsql); +} diff --git a/backend/src/main/java/com/yfd/platform/utils/QueryWrapperUtil.java b/backend/src/main/java/com/yfd/platform/utils/QueryWrapperUtil.java new file mode 100644 index 0000000..7d17396 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/utils/QueryWrapperUtil.java @@ -0,0 +1,634 @@ +package com.yfd.platform.utils; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 动态查询/动态 SQL 片段包装工具。 + *

+ * 主要用途: + *

+ * 1) 将前端 DevExtreme/Kendo 常见的 filter(数组嵌套 + and/or)解析为 MyBatis-Plus 的 {@link QueryWrapper} 条件;
+ * 2) 将 skip/take 转为 MyBatis-Plus 的 {@link Page};
+ * 3) 根据实体元数据把 Java 属性名映射为数据库列名({@link TableInfoHelper});
+ * 4) 生成 group by / order by 片段;
+ * 5) 反射提取对象字段值(用于调试/通用映射场景)。 + *

+ * 安全说明: + *

+ * - 本类只对“字段名/列名”做白名单校验({@link #SAFE_IDENTIFIER}),以避免把不安全字符串拼接进 SQL;
+ * - 条件值(value)通过 MyBatis-Plus 参数化方式参与查询,不做字符串拼接。 + */ +public class QueryWrapperUtil { + + /** + * Jackson JSON 解析器(用于把 DevExtreme/Kendo 风格的 filter 转为可遍历的 JsonNode)。 + */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * SQL 标识符白名单:仅允许字母/数字/下划线,且支持 a.b 这种带前缀写法。 + * 用于防止把不安全字符串拼进 SQL(字段名/列名场景)。 + */ + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$"); + + /** + * 针对“前端传 null”但后端需要特定默认值的字段列表(历史兼容)。 + */ + public static final List FIELDNULL = new ArrayList<>(); + + /** + * 获取某些字段在 JSON 过滤条件为 null 时的“替代值”。 + * 例如某些 GUID 字段在前端传 null,需要用全 0 GUID 参与过滤。 + */ + public static String getJsonFieldNull(String columnName) { + return FIELDNULL.contains(columnName) ? "00000000-0000-0000-0000-000000000000" : null; + } + + /** + * 把 DevExtreme 常用的分页参数 skip/take 转为 MyBatis-Plus 的 Page。 + * current 从 1 开始:current = skip / take + 1 + */ + public static Page getPage(Integer skip, Integer take) { + if (take == null || take <= 0) { + return null; + } + int s = skip == null ? 0 : Math.max(0, skip); + Page page = new Page<>(); + page.setSize(take.longValue()); + page.setCurrent(s / take + 1L); + return page; + } + + /** + * 使用 {@link DataSourceRequest}(take/skip)构造分页对象。 + */ + public static Page getPage(DataSourceRequest dataSourceRequest) { + if (dataSourceRequest == null) { + return null; + } + return getPage(dataSourceRequest.getSkip(), dataSourceRequest.getTake()); + } + + /** + * 根据分组描述拼接 SQL 的 group by / order by 片段。 + *

+ * 返回值示例:{@code " group by dept_id, role_id order by dept_id asc, role_id desc"}。 + * + * @param dataSourceRequest 仅使用其 group 字段 + * @return 可直接拼接到 SQL 末尾的片段;无分组返回 null + */ + public static String getGroupBy(DataSourceRequest dataSourceRequest) { + if (dataSourceRequest == null) { + return null; + } + List groupDescriptorList = dataSourceRequest.getGroup(); + if (groupDescriptorList == null || groupDescriptorList.isEmpty()) { + return null; + } + + List groupByParts = new ArrayList<>(); + List orderByParts = new ArrayList<>(); + + for (GroupDescriptor groupingInfo : groupDescriptorList) { + if (groupingInfo == null) { + continue; + } + String selector = requireSafeIdentifier(groupingInfo.getField()); + groupByParts.add(selector); + if (groupingInfo.isNeedSortFlag()) { + String dir = groupingInfo.getDir(); + if ("desc".equalsIgnoreCase(dir)) { + orderByParts.add(selector + " desc"); + } else { + orderByParts.add(selector + " asc"); + } + } + } + + if (groupByParts.isEmpty()) { + return null; + } + + StringBuilder groupResult = new StringBuilder(); + groupResult.append(" group by ").append(String.join(", ", groupByParts)); + if (!orderByParts.isEmpty()) { + groupResult.append(" order by ").append(String.join(", ", orderByParts)); + } + return groupResult.toString(); + } + + /** + * 通过反射提取对象字段名与字段值,组装为 Map。 + *

+ * - 传入实例对象:返回所有字段(包含父类字段)的值;
+ * - 传入 Class:仅返回 static 字段的值。 + * + * @param obj 实例对象或 Class + * @return 字段名-字段值;无字段或入参为空时返回 null + */ + public static Map getFieldValues(Object obj) { + if (obj == null) { + return null; + } + + Class type = (obj instanceof Class clazz) ? clazz : obj.getClass(); + List fields = getAllFields(type); + if (fields.isEmpty()) { + return null; + } + + Map valueMap = new HashMap<>(); + boolean isClassObject = obj instanceof Class; + for (Field field : fields) { + field.setAccessible(true); + Object value; + try { + if (isClassObject) { + if (!Modifier.isStatic(field.getModifiers())) { + continue; + } + value = field.get(null); + } else { + value = field.get(obj); + } + } catch (IllegalAccessException e) { + continue; + } + valueMap.put(field.getName(), value); + } + + return valueMap.isEmpty() ? null : valueMap; + } + + /** + * 从 DevExtreme/Kendo 风格 filter 中提取指定字段的过滤值(多个值用逗号拼接)。 + *

+ * filter 形态通常是数组嵌套: + * ["name","contains","abc"] 或 [ ["a","=","1"], "and", ["b","=","2"] ] + */ + public static String getFilterFieldValue(Object filter, String fieldName) { + if (filter == null || fieldName == null || fieldName.isBlank()) { + return null; + } + JsonNode root = toJsonNode(filter); + if (root == null || root.isNull()) { + return null; + } + StringBuilder sb = new StringBuilder(); + collectFieldValues(root, fieldName, sb); + return sb.isEmpty() ? null : sb.toString(); + } + + /** + * 便捷方法:从 filter 构建一个新的 QueryWrapper。 + * + * @param filter 前端 filter(可传 JSON 字符串 / List / JsonNode / 任意对象) + * @param modelClass MyBatis-Plus 实体类(用于 property->column 映射) + * @param validateColumn 是否校验字段必须存在于实体(true 更安全) + */ + public static QueryWrapper buildWrapperFromDevExtremeFilter(Object filter, Class modelClass, boolean validateColumn) { + QueryWrapper wrapper = new QueryWrapper<>(); + return applyDevExtremeFilter(wrapper, filter, modelClass, validateColumn, null, null); + } + + /** + * 把 DevExtreme/Kendo 风格 filter 应用到已有的 QueryWrapper 上。 + * + * @param fieldsMap 允许自定义字段映射(前端字段->数据库列名),优先级高于实体映射 + * @param removeFields 字段黑名单(前端传了也忽略) + */ + public static QueryWrapper applyDevExtremeFilter( + QueryWrapper wrapper, + Object filter, + Class modelClass, + boolean validateColumn, + Map fieldsMap, + List removeFields + ) { + if (wrapper == null || filter == null) { + return wrapper; + } + JsonNode root = toJsonNode(filter); + if (root == null || root.isNull()) { + return wrapper; + } + + Set remove = removeFields == null ? Collections.emptySet() : new HashSet<>(removeFields); + Map mapped = fieldsMap == null ? Collections.emptyMap() : new HashMap<>(fieldsMap); + applyFilterNode(root, wrapper, mapped, remove, modelClass, validateColumn); + return wrapper; + } + + /** + * 根据实体属性名获取数据库列名(默认要求属性必须存在于实体元数据中)。 + */ + public static String getDBColumnName(Class modelClass, String property) { + return getDBColumnName(modelClass, property, true); + } + + /** + * 根据 MyBatis-Plus 元数据,把实体属性名映射为数据库列名。 + *

+ * validateColumn=false 时,找不到映射会直接返回传入值(同时做一次安全校验)。 + */ + public static String getDBColumnName(Class modelClass, String property, boolean validateColumn) { + if (modelClass == null) { + throw new IllegalArgumentException("modelClass is null"); + } + if (property == null || property.isBlank()) { + throw new IllegalArgumentException("property is blank"); + } + TableInfo tableInfo = TableInfoHelper.getTableInfo(modelClass); + if (tableInfo == null) { + if (validateColumn) { + throw new IllegalArgumentException("TableInfo not found for " + modelClass.getName()); + } + return requireSafeIdentifier(property); + } + if (property.equals(tableInfo.getKeyProperty())) { + return tableInfo.getKeyColumn(); + } + List fieldInfos = tableInfo.getFieldList().stream() + .filter(f -> property.equals(f.getProperty())) + .collect(Collectors.toList()); + if (!fieldInfos.isEmpty()) { + return fieldInfos.get(0).getColumn(); + } + if (validateColumn) { + throw new IllegalArgumentException(property + "列找不到"); + } + return requireSafeIdentifier(property); + } + + /** + * 获取实体对应的数据库表名(来自 MyBatis-Plus 元数据)。 + */ + public static String getDBTableName(Class modelClass) { + if (modelClass == null) { + throw new IllegalArgumentException("modelClass is null"); + } + TableInfo tableInfo = TableInfoHelper.getTableInfo(modelClass); + if (tableInfo == null) { + throw new IllegalArgumentException("TableInfo not found for " + modelClass.getName()); + } + return tableInfo.getTableName(); + } + + /** + * 驼峰转分隔符小写:myFieldName -> my_field_name(hyphenation 可传 "_" 或 "-")。 + */ + public static String toHyphenation(String src, String hyphenation) { + if (src == null || src.isEmpty()) { + return src; + } + String h = hyphenation == null ? "" : hyphenation; + StringBuilder sb = new StringBuilder(src); + int cnt = 0; + for (int i = 1; i < src.length(); i++) { + if (Character.isUpperCase(src.charAt(i))) { + sb.insert(i + cnt, h); + cnt += h.length(); + } + } + return sb.toString().toLowerCase(Locale.ROOT); + } + + /** + * 驼峰转下划线。 + */ + public static String toUnderline(String src) { + return toHyphenation(src, "_"); + } + + /** + * 把不同类型的 filter 入参转成 JsonNode,便于递归解析。 + */ + private static JsonNode toJsonNode(Object filter) { + try { + if (filter instanceof JsonNode n) { + return n; + } + if (filter instanceof String s) { + if (s.isBlank()) { + return null; + } + return MAPPER.readTree(s); + } + return MAPPER.valueToTree(filter); + } catch (Exception e) { + return null; + } + } + + /** + * 递归遍历 filter,提取目标字段的值(多个值逗号拼接)。 + */ + private static void collectFieldValues(JsonNode node, String fieldName, StringBuilder sb) { + if (node == null || node.isNull()) { + return; + } + if (node.isArray()) { + if (node.size() >= 3 && node.get(0).isTextual()) { + String field = node.get(0).asText(); + if (fieldName.equals(field)) { + JsonNode valueNode = node.get(2); + String value = valueNode == null || valueNode.isNull() ? getJsonFieldNull(field) : valueNode.asText(); + if (value != null && !value.isBlank()) { + if (!sb.isEmpty()) { + sb.append(','); + } + sb.append(value); + } + } + return; + } + for (int i = 0; i < node.size(); i++) { + collectFieldValues(node.get(i), fieldName, sb); + } + } + } + + /** + * 把 filter(JsonNode 数组结构)递归解析并应用到 QueryWrapper: + * - 单条件:["field","=",value] + * - 组合条件:[ cond1, "and"/"or", cond2, ... ] + */ + private static void applyFilterNode( + JsonNode node, + QueryWrapper wrapper, + Map fieldsMap, + Set removeFields, + Class modelClass, + boolean validateColumn + ) { + if (node == null || node.isNull()) { + return; + } + if (!node.isArray()) { + return; + } + + // 形如:["field","=",value] + if (node.size() >= 3 && node.get(0).isTextual()) { + applySingleCondition(node, wrapper, fieldsMap, removeFields, modelClass, validateColumn); + return; + } + + // 形如:[ [ ... ] ],拆一层继续解析 + if (node.size() == 1) { + applyFilterNode(node.get(0), wrapper, fieldsMap, removeFields, modelClass, validateColumn); + return; + } + + // 先解析第 0 个条件,然后按 and/or 递归追加后续条件 + applyFilterNode(node.get(0), wrapper, fieldsMap, removeFields, modelClass, validateColumn); + for (int i = 2; i < node.size(); i += 2) { + JsonNode opNode = node.get(i - 1); + JsonNode exprNode = node.get(i); + String op = opNode == null ? null : opNode.asText(); + if ("and".equalsIgnoreCase(op)) { + wrapper.and(w -> applyFilterNode(exprNode, w, fieldsMap, removeFields, modelClass, validateColumn)); + } else if ("or".equalsIgnoreCase(op)) { + wrapper.or(w -> applyFilterNode(exprNode, w, fieldsMap, removeFields, modelClass, validateColumn)); + } + } + } + + /** + * 处理单个条件:["field","operator",value] 并映射到 MyBatis-Plus 条件方法。 + */ + private static void applySingleCondition( + JsonNode conditionNode, + QueryWrapper wrapper, + Map fieldsMap, + Set removeFields, + Class modelClass, + boolean validateColumn + ) { + String field = conditionNode.get(0).asText(); + String operator = conditionNode.get(1).asText(); + JsonNode valueNode = conditionNode.get(2); + + if (removeFields.contains(field)) { + return; + } + + // 列名优先从 fieldsMap 取,其次从实体元数据映射;并做一次安全校验 + String databaseColumnName = fieldsMap.get(field); + if (databaseColumnName == null) { + databaseColumnName = getDBColumnName(modelClass, field, validateColumn); + } else { + databaseColumnName = requireSafeIdentifier(databaseColumnName); + } + + Object value = convertJsonValue(valueNode, field); + + // operator 统一为小写,兼容前端大小写差异 + String op = operator == null ? "" : operator.trim().toLowerCase(Locale.ROOT); + switch (op) { + case "contains" -> wrapper.like(databaseColumnName, value); + case "notcontains" -> wrapper.notLike(databaseColumnName, value); + case "startswith" -> wrapper.likeRight(databaseColumnName, value); + case "endswith" -> wrapper.likeLeft(databaseColumnName, value); + case "=" -> { + if (value == null) { + wrapper.isNull(databaseColumnName); + } else { + wrapper.eq(databaseColumnName, value); + } + } + case "!=", + "<>" -> { + if (value == null) { + wrapper.isNotNull(databaseColumnName); + } else { + wrapper.ne(databaseColumnName, value); + } + } + case "<" -> wrapper.lt(databaseColumnName, value); + case "<=" -> wrapper.le(databaseColumnName, value); + case ">" -> wrapper.gt(databaseColumnName, value); + case ">=" -> wrapper.ge(databaseColumnName, value); + } + } + + /** + * 将 JsonNode 转成 Java 值:Boolean/Number/String/数组对象字符串化。 + * null 时按 getJsonFieldNull 做兼容替换。 + */ + private static Object convertJsonValue(JsonNode valueNode, String columnName) { + if (valueNode == null || valueNode.isNull()) { + return getJsonFieldNull(columnName); + } + if (valueNode.isBoolean()) { + return valueNode.asBoolean(); + } + if (valueNode.isNumber()) { + return valueNode.numberValue(); + } + if (valueNode.isTextual()) { + return valueNode.asText(); + } + if (valueNode.isArray() || valueNode.isObject()) { + return valueNode.toString(); + } + return valueNode.asText(); + } + + /** + * 校验列名/字段名为安全标识符,避免拼接 SQL 注入。 + */ + private static String requireSafeIdentifier(String identifier) { + String id = identifier == null ? "" : identifier.trim(); + if (id.isEmpty() || !SAFE_IDENTIFIER.matcher(id).matches()) { + throw new IllegalArgumentException("Unsafe SQL identifier: " + identifier); + } + return id; + } + + /** + * 获取类及其父类(直到 Object)的所有声明字段。 + */ + private static List getAllFields(Class type) { + if (type == null) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + Class current = type; + while (current != null && current != Object.class) { + Field[] declared = current.getDeclaredFields(); + if (declared != null) { + Collections.addAll(result, declared); + } + current = current.getSuperclass(); + } + return result; + } + + /** + * 通用请求 DTO(用于承载分页与分组信息)。 + *

+ * 说明:该 DTO 仅用于内部/工具层做参数承载,不绑定特定前端框架。 + */ + public static class DataSourceRequest { + /** + * 每页条数(take)。 + */ + private int take; + /** + * 跳过条数(skip)。 + */ + private int skip; + /** + * 分组描述列表(用于生成 group by/order by)。 + */ + private List group; + + public DataSourceRequest() { + } + + public DataSourceRequest(int take, int skip, List group) { + this.take = take; + this.skip = skip; + this.group = group; + } + + public int getTake() { + return take; + } + + public void setTake(int take) { + this.take = take; + } + + public int getSkip() { + return skip; + } + + public void setSkip(int skip) { + this.skip = skip; + } + + public List getGroup() { + return group; + } + + public void setGroup(List group) { + this.group = group; + } + } + + /** + * 分组描述 DTO:字段名 + 排序方向 + 是否需要排序。 + */ + public static class GroupDescriptor { + /** + * 分组字段名(建议仅传安全标识符,如:dept_id / t.dept_id)。 + */ + private String field; + /** + * 排序方向:asc/desc(忽略大小写)。 + */ + private String dir; + /** + * 是否需要把该分组字段加入 order by。 + */ + private boolean needSortFlag; + + public GroupDescriptor() { + } + + public GroupDescriptor(String field, String dir, boolean needSortFlag) { + this.field = field; + this.dir = dir; + this.needSortFlag = needSortFlag; + } + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } + + public String getDir() { + return dir; + } + + public void setDir(String dir) { + this.dir = dir; + } + + public boolean isNeedSortFlag() { + return needSortFlag; + } + + public void setNeedSortFlag(boolean needSortFlag) { + this.needSortFlag = needSortFlag; + } + } + + static { + // 历史兼容:parentId 为 null 时用全 0 GUID 参与过滤/比较 + FIELDNULL.add("parentId"); + } +} diff --git a/backend/src/test/java/com/yfd/platform/utils/QueryWrapperUtilTest.java b/backend/src/test/java/com/yfd/platform/utils/QueryWrapperUtilTest.java new file mode 100644 index 0000000..1603821 --- /dev/null +++ b/backend/src/test/java/com/yfd/platform/utils/QueryWrapperUtilTest.java @@ -0,0 +1,120 @@ +package com.yfd.platform.utils; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class QueryWrapperUtilTest { + + @Test + void getPage_shouldBuildPageBySkipAndTake() { + Page page = QueryWrapperUtil.getPage(20, 10); + Assertions.assertNotNull(page); + Assertions.assertEquals(10L, page.getSize()); + Assertions.assertEquals(3L, page.getCurrent()); + } + + @Test + void getPage_shouldBuildPageFromDataSourceRequest() { + QueryWrapperUtil.DataSourceRequest req = new QueryWrapperUtil.DataSourceRequest(); + req.setSkip(0); + req.setTake(15); + + Page page = QueryWrapperUtil.getPage(req); + Assertions.assertNotNull(page); + Assertions.assertEquals(15L, page.getSize()); + Assertions.assertEquals(1L, page.getCurrent()); + } + + @Test + void getGroupBy_shouldBuildGroupAndOrderSegment() { + QueryWrapperUtil.GroupDescriptor g1 = new QueryWrapperUtil.GroupDescriptor("dept_id", "asc", true); + QueryWrapperUtil.GroupDescriptor g2 = new QueryWrapperUtil.GroupDescriptor("role_id", "desc", true); + QueryWrapperUtil.DataSourceRequest req = new QueryWrapperUtil.DataSourceRequest(10, 0, List.of(g1, g2)); + + String sql = QueryWrapperUtil.getGroupBy(req); + Assertions.assertEquals(" group by dept_id, role_id order by dept_id asc, role_id desc", sql); + } + + @Test + void getGroupBy_shouldRejectUnsafeIdentifier() { + QueryWrapperUtil.GroupDescriptor bad = new QueryWrapperUtil.GroupDescriptor("name;drop", "asc", true); + QueryWrapperUtil.DataSourceRequest req = new QueryWrapperUtil.DataSourceRequest(10, 0, List.of(bad)); + + Assertions.assertThrows(IllegalArgumentException.class, () -> QueryWrapperUtil.getGroupBy(req)); + } + + @Test + void getFieldValues_shouldReadInstanceAndSuperclassFields() { + Child child = new Child(); + Map values = QueryWrapperUtil.getFieldValues(child); + + Assertions.assertNotNull(values); + Assertions.assertEquals("parent", values.get("parentField")); + Assertions.assertEquals(7, values.get("childField")); + } + + @Test + void getFieldValues_shouldReadOnlyStaticWhenInputIsClass() { + Map values = QueryWrapperUtil.getFieldValues(StaticHolder.class); + + Assertions.assertNotNull(values); + Assertions.assertEquals("staticValue", values.get("S")); + Assertions.assertFalse(values.containsKey("normalField")); + } + + @Test + void getFilterFieldValue_shouldExtractNestedFieldValues() { + String filter = "[[\"name\",\"contains\",\"alice\"],\"and\",[[\"name\",\"=\",\"bob\"],\"or\",[\"age\",\">\",18]]]"; + String value = QueryWrapperUtil.getFilterFieldValue(filter, "name"); + Assertions.assertEquals("alice,bob", value); + } + + @Test + void applyDevExtremeFilter_shouldBuildExpectedSqlAndRespectRemoveFields() { + String filter = "[[\"username\",\"contains\",\"zhang\"],\"and\",[\"status\",\"=\",1],\"or\",[\"email\",\"=\",null]]"; + Map fieldsMap = Map.of( + "username", "USER_NAME", + "status", "STATUS", + "email", "EMAIL" + ); + QueryWrapper wrapper = new QueryWrapper<>(); + + QueryWrapperUtil.applyDevExtremeFilter( + wrapper, + filter, + Object.class, + false, + fieldsMap, + List.of("status") + ); + + String sqlSegment = wrapper.getSqlSegment(); + Assertions.assertNotNull(sqlSegment); + Assertions.assertTrue(sqlSegment.contains("USER_NAME")); + Assertions.assertFalse(sqlSegment.contains("STATUS")); + Assertions.assertTrue(sqlSegment.contains("EMAIL")); + } + + @Test + void toUnderline_shouldConvertCamelCase() { + Assertions.assertEquals("user_name", QueryWrapperUtil.toUnderline("userName")); + } + + private static class Parent { + private String parentField = "parent"; + } + + private static class Child extends Parent { + private int childField = 7; + } + + private static class StaticHolder { + private static String S = "staticValue"; + private String normalField = "normal"; + } +} +