0. 前言
其实在有LLM的时代,做一个通用查询工具模块似乎比较多余。
出于对代码掌控以及编写方式的一些个人的要求,我将查询分为两类,一类是比较简单的单表以及复杂度不高的多表查询,另一类是需要用到.xml的复杂查询。
前者我觉得如果能遵循某一类范式作为Context,无论是复用者还是LLM,都能很好地遵循一个规则,在这个规则下写出更好审查以及追踪的代码,这就是使用通用查询工具的意义。
而后者本身在业务中占比不多,需要用到的时候肯定也会充分设计,在此基础上可以零星地用到通用查询的工具(比如说统计与传入字段约束),核心功能交由.xml去实现,我觉得是比较合理的实现。
注:本文不考虑纯粹的SQL拼接,完全依靠安全的
MyBatis-Plus的QueryWrapper拼接。
1. 拟定通用查询数据结构:BasePageRequest
一般而言,前端想要做一个查询,无非就是:
- 排序方式
- 字段+条件+查询值
- 页码
- 是否分页
- 分页大小
出于安全角度考虑,我们还得想到:
- 最大分页大小限制
- 最大排序字段限制(避免过于复杂的查询)
对于这种通用的查询数据结构,字段+条件+查询值必然需要声明约束,这个约束应该放在哪先不管,不过很明显我们可以定义一个泛型先顶着,于是便有了下面的BasePageRequest类:
@Datapublic class BasePageRequest<T> { public static final int MAX_ORDERS = 5; public static final int MAX_UNPAGED_SIZE = 1000; // 枚举化排序,避免输错 public enum OrderDirection { ASC, DESC, NATURAL;
public static OrderDirection from(String value) { if (value == null || value.isBlank()) {60 collapsed lines
return ASC; } return OrderDirection.valueOf(value.trim().toUpperCase()); } }
@Data public static class BaseOrderItem { @Schema(description = "排序字段") @NotBlank(message = "排序字段column不能为空") private String column;
@Schema(description = "排序方式", example = "ASC/DESC/NATURAL") @NotNull(message = "排序方式direction不能为空") private OrderDirection direction = OrderDirection.ASC;
public BaseOrderItem(String column, OrderDirection direction) { this.column = column; this.direction = direction; }
public static BaseOrderItem asc(String column) { return new BaseOrderItem(column, OrderDirection.ASC); }
public static BaseOrderItem desc(String column) { return new BaseOrderItem(column, OrderDirection.DESC); }
public void setDirection(String direction) { this.direction = OrderDirection.from(direction); } }
@Schema(description = "当前页码", example = "1~n | -1(不分页)") @Min(value = -1, message = "当前页码page必须>=-1") @NotNull(message = "当前页码page不能为空") private Integer page = 1;
@Schema(description = "是否不分页(优先级高于 page=-1)") private Boolean noPage = false;
@Schema(description = "分页大小", example = "10") @Min(value = 1, message = "每页最少1条") @Max(value = 100, message = "每页最多100条") @NotNull(message = "当前分页大小size不能为空") private Integer size = 10;
@Schema(description = "排序规则列表") @Valid @Size(max = MAX_ORDERS, message = "排序字段最多支持5个") private List<BaseOrderItem> orders;
@Schema(description = "动态查询条件") @Valid private T query;
public boolean isUnpaged() { if (Boolean.TRUE.equals(noPage)) { return true; } return page != null && page < 0; }}2. 拟定数据库查询条件枚举类:FilterOp
这个倒是没什么好说的,主要就是统一管理+收敛所有查询条件为枚举类。
public enum FilterOp { EQ, NE, GT, GE, LT, LE, LIKE, NOT_LIKE, LIKE_LEFT, LIKE_RIGHT, NOT_LIKE_LEFT, NOT_LIKE_RIGHT, BETWEEN, NOT_BETWEEN, IN, NOT_IN, IS_NULL, IS_NOT_NULL}3. BasePageRequest中泛型的基本数据结构:FilterCondition
也就是字段+条件+查询值,一个最小可用的查询atomic结构,通用于所有查询字段的同时也能跟最终的映射解耦。
出于约束角度考虑,字段与查询条件都不能为空,但值的话必须可以为空,因为有IS_NULL,IS_NOT_NULL之类的无值字段查询。
@Datapublic class FilterCondition { @NotBlank(message = "查询字段不能为空") private String field; @NotNull(message = "查询条件不能为空") private FilterOp op; private Object value;}4. 树状查询表达式:AND/OR分组(QueryLogic枚举与QueryGroup)
满足 (A OR B) AND (C OR D)这种复杂查询。
当然,也就到此为止,再复杂一些的直接扔给.xml去解决,不要浪费力气在通用结构上,容易陷入过度抽象的陷阱。
QueryLogic
public enum QueryLogic { AND, OR}QueryGroup
其实这个filters与group限制在20与10其实只是遵循JSR-303 约束,按照自己业务情况来吧。
@Datapublic class QueryGroup { public static final int MAX_GROUPS = 10;
private QueryLogic logic = QueryLogic.AND;
@Valid @Size(max = QueryFilters.MAX_FILTERS, message = "过滤条件最多支持20个") private List<FilterCondition> filters;
@Valid @Size(max = MAX_GROUPS, message = "分组最多支持10个") private List<QueryGroup> groups;}5. 查询请求入口:QueryFilters
其实它本质上跟QueryGroup是一个东西,但掌管的领域不同,QueryGroup是给树状结构查询定义的,而这个类是作为入口直接跟前端接触的类,这个在后面其它类做处理的时候会有所体现。
为什么 QueryFilters 与 QueryGroup 分开
-
QueryFilters是请求入口:其承载“简单列表”和“树状分组”,并约束filters/group互斥。 -
QueryGroup是递归节点:只负责logic + filters + groups,用于表达(A OR B) AND (C OR D)之类复杂结构。的
分离的好处:简单查询不需要树结构,复杂查询才进入递归,避免所有请求都背负树模型成本。
@Datapublic class QueryFilters { public static final int MAX_FILTERS = 20;
@Valid @Size(max = MAX_FILTERS, message = "过滤条件最多支持20个") private List<FilterCondition> filters;
@Valid private QueryGroup group;}6. 声明约束:QueryFieldSpec
定义:
- 某个字段允许的操作符
- 列名映射(多表查询时需要将某个字段映射成
表名alias.字段这样的格式) - 自动模糊(
SmartLike=%value%) - 值转换器(比如说,前端传
timestamp需要转成OffsetDateTime落库,当然,这个肯定会做通用处理,只是说是这么种用法)
public class QueryFieldSpec { private final String column; private final EnumSet<FilterOp> allowedOps; private final boolean autoLike; private final Function<Object, Object> valueConverter;
public QueryFieldSpec(String column, EnumSet<FilterOp> allowedOps, boolean autoLike, Function<Object, Object> valueConverter) { this.column = Objects.requireNonNull(column, "column"); this.allowedOps = Objects.requireNonNull(allowedOps, "allowedOps");41 collapsed lines
this.autoLike = autoLike; this.valueConverter = valueConverter; }
public QueryFieldSpec(String column, EnumSet<FilterOp> allowedOps, boolean autoLike) { this(column, allowedOps, autoLike, null); }
public static QueryFieldSpec of(String column, EnumSet<FilterOp> allowedOps) { return new QueryFieldSpec(column, allowedOps, false); }
public static QueryFieldSpec of(String column, EnumSet<FilterOp> allowedOps, boolean autoLike) { return new QueryFieldSpec(column, allowedOps, autoLike); }
public static QueryFieldSpec of(String column, EnumSet<FilterOp> allowedOps, boolean autoLike, Function<Object, Object> valueConverter) { return new QueryFieldSpec(column, allowedOps, autoLike, valueConverter); }
public static QueryFieldSpec of(String column, EnumSet<FilterOp> allowedOps, Function<Object, Object> valueConverter) { return new QueryFieldSpec(column, allowedOps, false, valueConverter); }
public String getColumn() { return column; }
public EnumSet<FilterOp> getAllowedOps() { return allowedOps; }
public boolean isAutoLike() { return autoLike; }
public Function<Object, Object> getValueConverter() { return valueConverter; }}7. 值通用转换: QueryValueConverters
将前端 value 转为后端真实类型(时间、枚举、数字),就是前面说的值转换通用处理。
其实就是干脏活的。
public final class QueryValueConverters { private static final DateTimeFormatter DEFAULT_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private QueryValueConverters() { }
public static Function<Object, Object> toLong() { return value -> { if (value instanceof Long) { return value; }132 collapsed lines
if (value instanceof Number number) { return number.longValue(); } if (value instanceof String text) { String trimmed = text.trim(); if (trimmed.isEmpty()) { return null; } return Long.parseLong(trimmed); } throw new IllegalArgumentException("Unsupported value type for Long: " + value.getClass().getName()); }; }
public static Function<Object, Object> toInteger() { return value -> { if (value instanceof Integer) { return value; } if (value instanceof Number number) { return number.intValue(); } if (value instanceof String text) { String trimmed = text.trim(); if (trimmed.isEmpty()) { return null; } return Integer.parseInt(trimmed); } throw new IllegalArgumentException("Unsupported value type for Integer: " + value.getClass().getName()); }; }
public static Function<Object, Object> toBigDecimal() { return value -> { if (value instanceof BigDecimal) { return value; } if (value instanceof Number number) { return BigDecimal.valueOf(number.doubleValue()); } if (value instanceof String text) { String trimmed = text.trim(); if (trimmed.isEmpty()) { return null; } return new BigDecimal(trimmed); } throw new IllegalArgumentException("Unsupported value type for BigDecimal: " + value.getClass().getName()); }; }
public static Function<Object, Object> toBoolean() { return value -> { if (value instanceof Boolean) { return value; } if (value instanceof String text) { String trimmed = text.trim(); if (trimmed.isEmpty()) { return null; } return Boolean.parseBoolean(trimmed); } throw new IllegalArgumentException("Unsupported value type for Boolean: " + value.getClass().getName()); }; }
public static Function<Object, Object> toOffsetDateTime() { return value -> { if (value instanceof OffsetDateTime) { return value; } if (value instanceof LocalDateTime localDateTime) { return localDateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime(); } if (value instanceof Number number) { return OffsetDateTime.ofInstant(Instant.ofEpochMilli(number.longValue()), ZoneId.systemDefault()); } if (value instanceof String text) { String trimmed = text.trim(); if (trimmed.isEmpty()) { return null; } if (trimmed.chars().allMatch(Character::isDigit)) { long timestamp = Long.parseLong(trimmed); return OffsetDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()); } try { LocalDateTime ldt = LocalDateTime.parse(trimmed, DEFAULT_DATETIME_FORMATTER); return ldt.atZone(ZoneId.systemDefault()).toOffsetDateTime(); } catch (DateTimeParseException ex) { return OffsetDateTime.parse(trimmed); } } throw new IllegalArgumentException("Unsupported value type for OffsetDateTime: " + value.getClass().getName()); }; }
public static <E extends Enum<E>> Function<Object, Object> enumByName(Class<E> enumType) { Objects.requireNonNull(enumType, "enumType"); return value -> { if (value == null) { return null; } if (enumType.isInstance(value)) { return value; } if (value instanceof String text) { String trimmed = text.trim(); if (trimmed.isEmpty()) { return null; } return Enum.valueOf(enumType, trimmed); } throw new IllegalArgumentException("Unsupported value type for enum: " + value.getClass().getName()); }; }
public static <E> Function<Object, Object> enumByResolver(Function<String, E> resolver) { Objects.requireNonNull(resolver, "resolver"); return value -> { if (value == null) { return null; } if (value instanceof String text) { String trimmed = text.trim(); if (trimmed.isEmpty()) { return null; } return resolver.apply(trimmed); } throw new IllegalArgumentException("Unsupported value type for enum resolver: " + value.getClass().getName()); }; }}8. 查询条件应用拼接:QueryFilterApplier
既然有了约束,就会有校验,校验完了后就要应用到QueryWrapper上。
这个类主要实现以下功能:
- 校验字段是否在白名单内。
between/in/like这种并非单值的情况处理。- 普通单值条件处理
group条件处理- 使用
QueryWrapper拼接查询条件
在这里写可以做到查询逻辑集中化,避免各个业务重复写拼装的逻辑。
然后呢,则是可以做一个统一的异常处理与拒绝条件。
对于group的处理很简单
nested:保证分组语义。例如(A AND B) OR (C AND D),必须用nested包住子组。and/or:用于连接已有条件与新条件的关系。如果在第一个子组上直接用and,可能丢失括号导致语义错误。- 当前实现:首子组用
nested,后续子组根据logic使用and/or,保证 OR 分组不被展开。
public final class QueryFilterApplier { private QueryFilterApplier() {}
public static <T, W extends AbstractWrapper<T, String, W>> void apply(W wrapper, List<FilterCondition> filters, Map<String, QueryFieldSpec> fieldSpecs) { if (filters == null || filters.isEmpty()) { return; } if (filters.size() > QueryFilters.MAX_FILTERS) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "filters.size"); }201 collapsed lines
for (FilterCondition filter : filters) { if (filter == null || filter.getField() == null || filter.getOp() == null) { continue; } QueryFieldSpec spec = fieldSpecs.get(filter.getField()); if (spec == null) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_NOT_SUPPORT, filter.getField()); } if (!spec.getAllowedOps().contains(filter.getOp())) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_NOT_SUPPORT, filter.getOp()); } applySingle(wrapper, spec, filter); } }
public static <T, W extends AbstractWrapper<T, String, W>> void apply(W wrapper, QueryFilters query, Map<String, QueryFieldSpec> fieldSpecs) { if (query == null) { return; } if (query.getGroup() != null && query.getFilters() != null && !query.getFilters().isEmpty()) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "filters.group.conflict"); } if (query.getGroup() != null) { applyGroup(wrapper, query.getGroup(), fieldSpecs); return; } apply(wrapper, query.getFilters(), fieldSpecs); }
private static <T, W extends AbstractWrapper<T, String, W>> void applySingle(W wrapper, QueryFieldSpec spec, FilterCondition filter) { Object value = normalizeValue(spec, filter.getOp(), filter.getValue()); FilterOp op = filter.getOp(); String column = spec.getColumn();
if (value == null && op != FilterOp.IS_NULL && op != FilterOp.IS_NOT_NULL) { if (op == FilterOp.IN) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "in.empty"); } if (op == FilterOp.NOT_IN) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "notIn.empty"); } return; }
switch (op) { case EQ -> wrapper.eq(column, value); case NE -> wrapper.ne(column, value); case GT -> wrapper.gt(column, value); case GE -> wrapper.ge(column, value); case LT -> wrapper.lt(column, value); case LE -> wrapper.le(column, value); case LIKE -> wrapper.like(column, normalizeLikeValue(spec, op, value)); case NOT_LIKE -> wrapper.notLike(column, normalizeLikeValue(spec, op, value)); case LIKE_LEFT -> wrapper.likeLeft(column, value); case LIKE_RIGHT -> wrapper.likeRight(column, value); case NOT_LIKE_LEFT -> wrapper.notLikeLeft(column, value); case NOT_LIKE_RIGHT -> wrapper.notLikeRight(column, value); case BETWEEN -> { List<?> values = normalizeToList(value); if (values == null || values.size() != 2) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "between"); } wrapper.between(column, values.get(0), values.get(1)); } case NOT_BETWEEN -> { List<?> values = normalizeToList(value); if (values == null || values.size() != 2) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "notBetween"); } wrapper.notBetween(column, values.get(0), values.get(1)); } case IN -> { List<?> values = normalizeToList(value); if (values == null || values.isEmpty()) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "in.empty"); } wrapper.in(column, values); } case NOT_IN -> { List<?> values = normalizeToList(value); if (values == null || values.isEmpty()) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "notIn.empty"); } wrapper.notIn(column, values); } case IS_NULL -> wrapper.isNull(column); case IS_NOT_NULL -> wrapper.isNotNull(column); } }
private static <T, W extends AbstractWrapper<T, String, W>> void applyGroup(W wrapper, QueryGroup group, Map<String, QueryFieldSpec> fieldSpecs) { if (group == null) { return; } QueryLogic logic = group.getLogic() == null ? QueryLogic.AND : group.getLogic(); List<FilterCondition> filters = group.getFilters(); if (filters != null && filters.size() > QueryFilters.MAX_FILTERS) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "filters.size"); } int clauses = 0; if (filters != null) { for (FilterCondition filter : filters) { if (filter == null || filter.getField() == null || filter.getOp() == null) { continue; } QueryFieldSpec spec = fieldSpecs.get(filter.getField()); if (spec == null) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_NOT_SUPPORT, filter.getField()); } if (!spec.getAllowedOps().contains(filter.getOp())) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_NOT_SUPPORT, filter.getOp()); } if (logic == QueryLogic.OR && clauses > 0) { wrapper.or(); } applySingle(wrapper, spec, filter); clauses++; } }
List<QueryGroup> groups = group.getGroups(); if (groups != null && groups.size() > QueryGroup.MAX_GROUPS) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "groups.size"); } if (groups != null) { for (QueryGroup child : groups) { if (child == null) { continue; } if (clauses == 0) { wrapper.nested(w -> applyGroup(w, child, fieldSpecs)); } else if (logic == QueryLogic.OR) { wrapper.or(w -> applyGroup(w, child, fieldSpecs)); } else { wrapper.and(w -> applyGroup(w, child, fieldSpecs)); } clauses++; } } }
private static Object normalizeValue(QueryFieldSpec spec, FilterOp op, Object value) { if (value == null) { return null; } Function<Object, Object> converter = spec.getValueConverter(); if (converter == null) { return value; } try { if (op == FilterOp.IN || op == FilterOp.NOT_IN || op == FilterOp.BETWEEN || op == FilterOp.NOT_BETWEEN) { List<?> values = normalizeToList(value); if (values == null) { return value; } List<Object> converted = new ArrayList<>(values.size()); for (Object item : values) { converted.add(converter.apply(item)); } return converted; } return converter.apply(value); } catch (RuntimeException ex) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "value.convert"); } }
private static Object normalizeLikeValue(QueryFieldSpec spec, FilterOp op, Object value) { if (!spec.isAutoLike() || !(value instanceof String)) { return value; } if (op != FilterOp.LIKE && op != FilterOp.NOT_LIKE) { return value; } String raw = (String) value; return "%" + raw + "%"; }
private static List<?> normalizeToList(Object value) { if (value == null) { return null; } if (value instanceof List<?> list) { return list; } if (value instanceof Collection<?> collection) { return List.copyOf(collection); } if (value.getClass().isArray()) { int length = Array.getLength(value); Object[] array = new Object[length]; for (int i = 0; i < length; i++) { array[i] = Array.get(value, i); } return List.of(array); } return null; }}9. Query模块Exception: QueryException
前面抛出了这么个异常但没定义,这里补上,其实就是一个常规的异常而已。
@Getterpublic class QueryException extends RuntimeException { private final int code; private final Object data;
public QueryException(ResponseCode responseCode, Object data) { super(responseCode.getMessage()); this.code = responseCode.getCode(); this.data = data; }
public QueryException(ResponseCode responseCode, String message, Object data) { super(message); this.code = responseCode.getCode(); this.data = data; }}10. 分页排序与构建: QueryPageBuilder
虽然前面的入口做了点处理,但这边再做一次也没什么。
主要做分页边界控制、orders数量、排序字段、排序逻辑做统一处理与校验。
public final class QueryPageBuilder { private QueryPageBuilder() {}
public static <T> IPage<T> buildPage(BasePageRequest<?> request, Map<String, String> orderColumns) { List<BasePageRequest.BaseOrderItem> orders = request.getOrders(); if (orders != null && orders.size() > BasePageRequest.MAX_ORDERS) { // orders数量约束 throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "orders.size"); } long size = resolveSize(request); long current = resolveCurrent(request); Page<T> page = new Page<>(current, size);49 collapsed lines
if (request.isUnpaged()) { page.setSearchCount(false); } if (orders == null || orders.isEmpty()) { return page; } for (BasePageRequest.BaseOrderItem order : orders) { BasePageRequest.OrderDirection direction = order.getDirection(); if (direction == null) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "null"); } if (direction == BasePageRequest.OrderDirection.NATURAL) { continue; } String column = resolveOrderColumn(orderColumns, order.getColumn()); if (column == null) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_NOT_SUPPORT, order.getColumn()); } switch (direction) { case ASC -> page.orders().add(OrderItem.asc(column)); case DESC -> page.orders().add(OrderItem.desc(column)); default -> throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, direction); } } return page; } // orders字段白名单排查 private static String resolveOrderColumn(Map<String, String> orderColumns, String field) { if (field == null) { return null; } if (orderColumns == null || orderColumns.isEmpty()) { return field; } return orderColumns.get(field); }
private static long resolveCurrent(BasePageRequest<?> request) { if (request.isUnpaged()) { return 1L; } Integer page = request.getPage(); return page == null ? 1L : page; } // size约束,MAX_UNPAGED_SIZE为不分页时最大返回数量。 private static long resolveSize(BasePageRequest<?> request) { if (request.isUnpaged()) { return BasePageRequest.MAX_UNPAGED_SIZE; } Integer size = request.getSize(); return size == null ? 10L : size; }}11. Query模块异常追踪:QueryTraceService/QueryTraceSupport
对查询条件数量、排序数量、不分页语义做指标与 tag。
做这么个东西,也是为了方便查询出问题的时候方便我们定位出异常源。
同时也方便对查询进行统计,对后续优化与审计做数据支持。
QueryTraceService
package com.zwlh.powergrid.contract.common.query.unified;
import io.micrometer.tracing.Span;import io.micrometer.tracing.Tracer;import org.springframework.stereotype.Component;
@Componentpublic class QueryTraceService {
private final Tracer tracer; private static final int MAX_COUNT_TAG = 5;
42 collapsed lines
public QueryTraceService(Tracer tracer) { this.tracer = tracer; }
public void tagFilters(int count) { Span span = tracer.currentSpan(); if (span == null) { return; } span.tag("mp.filters.count", normalizeCount(count)); }
public void tagOrders(int count) { Span span = tracer.currentSpan(); if (span == null) { return; } span.tag("mp.orders.count", normalizeCount(count)); }
public void tagUnpaged(boolean unpaged) { Span span = tracer.currentSpan(); if (span == null) { return; } span.tag("mp.query.unpaged", String.valueOf(unpaged)); }
public void tagReject(String reason) { Span span = tracer.currentSpan(); if (span == null) { return; } span.tag("mp.query.reject", reason == null ? "unknown" : reason); }
private String normalizeCount(int count) { if (count < 0) { return "0"; } if (count > MAX_COUNT_TAG) { return MAX_COUNT_TAG + "+"; } return String.valueOf(count); }}QueryTraceSupport
public final class QueryTraceSupport { private static final int DEFAULT_LIMIT = 8;
private QueryTraceSupport() { }
public static int count(Collection<?> items) { return items == null ? 0 : items.size(); }
public static String summarizeFilterOpssummarizeFilterFields(List<FilterCondition> filters) { return summarize(filters, FilterCondition::getField);23 collapsed lines
}
public static String summarizeFilterOps(List<FilterCondition> filters) { return summarize(filters, filter -> filter.getOp() == null ? null : filter.getOp().name()); }
public static String summarizeOrderColumns(List<BasePageRequest.BaseOrderItem> orders) { return summarize(orders, BasePageRequest.BaseOrderItem::getColumn); }
public static String summarizeOrderDirections(List<BasePageRequest.BaseOrderItem> orders) { return summarize(orders, order -> order.getDirection() == null ? null : order.getDirection().name()); }
private static <T> String summarize(List<T> items, Function<T, String> mapper) { if (items == null || items.isEmpty()) { return ""; } return items.stream() .map(mapper) .filter(Objects::nonNull) .distinct() .limit(DEFAULT_LIMIT) .collect(Collectors.joining(",")); }}12. Query模块错误管理: QueryExceptionHandler
老规矩,每个模块自己有自己的exception handler,也避免跟别的模块杂糅在一起处理。
@RestControllerAdvice@RequiredArgsConstructor@Slf4jpublic class QueryExceptionHandler { private final QueryTraceService queryTraceService;
@ExceptionHandler(QueryException.class) public ResponseEntity<Result<?>> handleQueryException(QueryException ex) { queryTraceService.tagReject(String.valueOf(ex.getData())); log.warn("Query异常: code={}, msg={}, data={}", ex.getCode(), ex.getMessage(), ex.getData()); return ResponseEntity.status(HttpStatus.OK.value()) .body(Result.error(ex.getCode(), ex.getMessage(), ex.getData())); }}13. 使用示例
Controller
@PostMapping("/approval/process/page")public IPage<ApprovalProcess> page(@RequestBody BasePageRequest<QueryFilters> req) { return approvalProcessService.page(req);}Repository
先定义查询字段的白名单过滤字段以及排序字段。
private static final Map<String, QueryFieldSpec> PROCESS_FILTERS = Map.of( "processName", QueryFieldSpec.of("process_name", EnumSet.of(FilterOp.EQ, FilterOp.LIKE), true), "isActive", QueryFieldSpec.of("is_active", EnumSet.of(FilterOp.EQ), QueryValueConverters.toBoolean()));
private static final Map<String, String> PROCESS_ORDERS = Map.of( "processName", "process_name", "createdTime", "created_time", "updatedTime", "updated_time", "isActive", "is_active");当然,有人可能会想着把这些声明逻辑藏在某个DTO里面,直接在字段上面加上注解,我选择在Repository里面写主要是考虑到以下因素:
- 其实在哪里写都是要写的,放
DTO里面的话,我们还要做个Aspect,也没有省多少功夫的同时还引入多一个AOP,并没有降低工作量。 - 同一个
DTO可能会碰到以下场景:在A 接口允许模糊查,在 B 接口只允许精确查,难不成要为了一个字段又创建一个DTO? Repository本来就是干脏累活的地方,而且现在有LLM了让它给你生成就是,跟生成糊糊代码本质的不同点就在于这坨糊糊的逻辑清晰,本质上只是声明式内容,比起你显式解析一堆入参还要校验来说,这点东西LLM几乎可以说几乎没有幻觉的同时,代码人类也完全看得懂。
不过这么写很容易出错,我们可以加个Lambda解析:
public final class MpColumnUtil { private MpColumnUtil() { }
public static <T> String col(SFunction<T, ?> func) { return resolveColumn(func); }
public static <T> String col(String alias, SFunction<T, ?> func) { if (alias == null || alias.isBlank()) { return resolveColumn(func); }17 collapsed lines
return alias + "." + resolveColumn(func); }
private static <T> String resolveColumn(SFunction<T, ?> func) { if (func == null) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "lambda.null"); } LambdaMeta meta = LambdaUtils.extract(func); String fieldName = PropertyNamer.methodToProperty(meta.getImplMethodName()); Class<?> entityClass = meta.getInstantiatedClass(); Map<String, ColumnCache> columnMap = LambdaUtils.getColumnMap(entityClass); if (columnMap == null) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "tableInfo.missing"); } ColumnCache cache = columnMap.get(LambdaUtils.formatKey(fieldName)); if (cache == null) { throw new QueryException(ResponseCode.SYSTEM_QUERY_CONDITION_ERROR, "column.missing"); } return cache.getColumn(); }}其实就是包个语法糖,调用方式变动了一下而已:
private static final Map<String, QueryFieldSpec> PROCESS_FILTERS = Map.of( "processName", QueryFieldSpec.of(MpColumnUtil.col(ApprovalProcess::getProcessName), EnumSet.of(FilterOp.EQ, FilterOp.LIKE), true), "isActive", QueryFieldSpec.of(MpColumnUtil.col(ApprovalProcess::getIsActive), EnumSet.of(FilterOp.EQ), QueryValueConverters.toBoolean()) );
private static final Map<String, String> PROCESS_ORDERS = Map.of( "processName", MpColumnUtil.col(ApprovalProcess::getProcessName), "createdTime", MpColumnUtil.col(ApprovalProcess::getCreatedTime), "updatedTime", MpColumnUtil.col(ApprovalProcess::getUpdatedTime), "isActive", MpColumnUtil.col(ApprovalProcess::getIsActive) );再进一步的builder构建似乎就没什么必要了,有兴趣可以自己实现。
然后应用过滤与分页:
QueryWrapper<ApprovalProcess> wrapper = new QueryWrapper<>();QueryFilterApplier.apply(wrapper, req.getQuery(), PROCESS_FILTERS);IPage<ApprovalProcess> page = QueryPageBuilder.buildPage(req, PROCESS_ORDERS);return approvalProcessMapper.selectPage(page, wrapper);完整的Repository示例如下:
@Repository@RequiredArgsConstructorpublic class ApprovalProcessQueryRepositoryImpl implements ApprovalProcessQueryRepository { private static final Map<String, QueryFieldSpec> PROCESS_FILTERS = Map.of( "processName", QueryFieldSpec.of("process_name", EnumSet.of(FilterOp.EQ, FilterOp.LIKE), true), "isActive", QueryFieldSpec.of("is_active", EnumSet.of(FilterOp.EQ), QueryValueConverters.toBoolean()) );
private static final Map<String, String> PROCESS_ORDERS = Map.of( "processName", "process_name", "createdTime", "created_time", "updatedTime", "updated_time",18 collapsed lines
"isActive", "is_active" );
private final ApprovalProcessMapper approvalProcessMapper;
@Override public IPage<ApprovalProcess> findPage(BasePageRequest<QueryFilters> basePageRequest) { QueryWrapper<ApprovalProcess> queryWrapper = new QueryWrapper<>(); List<FilterCondition> filters = null; if (basePageRequest.getQuery() != null) { filters = basePageRequest.getQuery().getFilters(); QueryFilterApplier.apply(queryWrapper, basePageRequest.getQuery(), PROCESS_FILTERS); } IPage<ApprovalProcess> iPage = QueryPageBuilder.buildPage(basePageRequest, PROCESS_ORDERS); return approvalProcessMapper.selectPage(iPage, queryWrapper); }
@Override public ApprovalProcess findByProcessName(String processName) { return approvalProcessMapper.selectOne(new QueryWrapper<ApprovalProcess>().eq("process_name", processName)); }}Service
/** * 获取审批流程pages * @param basePageRequest page基本请求体 * @return IPage<ApprovalProcess> */ public IPage<ApprovalProcess> page(BasePageRequest<QueryFilters> basePageRequest) { List<FilterCondition> filters = null; if (basePageRequest.getQuery() != null) { filters = basePageRequest.getQuery().getFilters(); } if (QueryTraceSupport.count(filters) > 0) { queryTraceService.tagFilters( QueryTraceSupport.count(filters), ); } List<BasePageRequest.BaseOrderItem> orders = basePageRequest.getOrders(); if (QueryTraceSupport.count(orders) > 0) { queryTraceService.tagOrders( QueryTraceSupport.count(orders) ); } queryTraceService.tagUnpaged(basePageRequest.isUnpaged()); return approvalProcessQueryRepository.findPage(basePageRequest); }当然,每个Service层都要这么写查询追踪是非常繁琐以及没有意义的事情,我们来做个Aspect吧:
定义:
/** * Query 请求追踪标记 * 用于统一记录查询过滤/排序/不分页等指标 */@Target({ElementType.METHOD, ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface QueryTrace {}实现:
@Aspect@Component@RequiredArgsConstructorpublic class QueryTraceAspect { private final QueryTraceService queryTraceService;
@Before("@annotation(queryTrace)") public void trace(JoinPoint joinPoint, QueryTrace queryTrace) { BasePageRequest<?> request = findBasePageRequest(joinPoint.getArgs()); if (request == null) { return; }27 collapsed lines
List<FilterCondition> filters = null; Object query = request.getQuery(); if (query instanceof QueryFilters queryFilters) { filters = queryFilters.getFilters(); } if (QueryTraceSupport.count(filters) > 0) { queryTraceService.tagFilters( QueryTraceSupport.count(filters) ); } List<BasePageRequest.BaseOrderItem> orders = request.getOrders(); if (QueryTraceSupport.count(orders) > 0) { queryTraceService.tagOrders( QueryTraceSupport.count(orders) ); } queryTraceService.tagUnpaged(request.isUnpaged()); }
private BasePageRequest<?> findBasePageRequest(Object[] args) { if (args == null) { return null; } for (Object arg : args) { if (arg instanceof BasePageRequest<?> request) { return request; } } return null; }}直接在方法上面加上注解,这样Service层就没那么多干扰噪音:
@QueryTrace public IPage<ApprovalProcess> approvalProcessPage(BasePageRequest<ApprovalProcessPageDTO> basePageRequest) throws IllegalAccessException { return approvalProcessQueryRepository.findPage(basePageRequest); }14. 引用MyBatis-Plus-Join插件+Alias别名映射解决多表查询问题
以UserQueryRepositoryImpl为例:
注意!并非银弹,当查询复杂度超过一定程度后,请发挥
MyBatis-Plus自身拥有的优势,在.xml中直接写SQL,不要本末倒置!
@Repository@RequiredArgsConstructorpublic class UserQueryRepositoryImpl implements UserQueryRepository { private static final Map<String, QueryFieldSpec> USER_FILTERS = Map.of( "name", QueryFieldSpec.of(MpColumnUtil.col("uai", UserAdditionalInfo::getName), EnumSet.of(FilterOp.EQ, FilterOp.LIKE), true), "phone", QueryFieldSpec.of(MpColumnUtil.col("u", User::getPhone), EnumSet.of(FilterOp.LIKE), true), "registerType", QueryFieldSpec.of(MpColumnUtil.col("uai", UserAdditionalInfo::getRegisterType), EnumSet.of(FilterOp.EQ), QueryValueConverters.toInteger()), "identity", QueryFieldSpec.of(MpColumnUtil.col("uai", UserAdditionalInfo::getIdentity), EnumSet.of(FilterOp.EQ, FilterOp.LIKE)), "discriminator", QueryFieldSpec.of(MpColumnUtil.col("uai", UserAdditionalInfo::getDiscriminator), EnumSet.of(FilterOp.EQ), QueryValueConverters.toInteger()) );
private static final Map<String, String> USER_ORDERS = Map.of( "id", MpColumnUtil.col("u", User::getId), "phone", MpColumnUtil.col("u", User::getPhone), "createdTime", MpColumnUtil.col("u", User::getCreatedTime), "name", MpColumnUtil.col("uai", UserAdditionalInfo::getName), "registerType", MpColumnUtil.col("uai", UserAdditionalInfo::getRegisterType), "discriminator", MpColumnUtil.col("uai", UserAdditionalInfo::getDiscriminator) );
@Override public IPage<UserPageVo> findUserPage(BasePageRequest<UserPageDTO> basePageRequest) throws IllegalAccessException { MPJQueryWrapper<User> queryWrapper = JoinWrappers.query(User.class); QueryFilterApplier.apply(queryWrapper, basePageRequest.getQuery(), USER_FILTERS); IPage<UserPageVo> page = QueryPageBuilder.buildPage(basePageRequest, USER_ORDERS); return userMapper.getUsersPage(page, queryWrapper); }}15. 前端请求示例:
{ "page": 1, "size": 20, "orders": [{ "column": "createdTime", "direction": "DESC" }], "query": { "filters": [ { "field": "processName", "op": "LIKE", "value": "合同" }, { "field": "isActive", "op": "EQ", "value": true } ] }}16. 总结与注意事项
流程:
- Controller 接收
BasePageRequest<QueryFilters>。 QueryFilterApplier校验并写入QueryWrapper。QueryPageBuilder生成IPage(分页 + 排序)。Mapper执行selectPage。- 相对简单的多表查询采用
MyBatis-Plus-Join插件+Alias别名映射方案。 Service层使用@QueryTrace注解收集信息。- 若字段/查询条件非法,抛出
QueryException,由QueryExceptionHandler统一返回错误并打tag。
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐│ Controller │ ──▶ │ QueryFilterApplier│ ──▶ │ QueryWrapper │└─────────────┘ └──────────────────┘ └─────────────────┘ │ │ ▼ ▼┌─────────────┐ ┌─────────────────┐│ QueryPage │ ────────────────────────────▶│ Mapper.selectPage││ Builder │ └─────────────────┘└─────────────┘所有通用性方案都会有自己的边界,通用性方案解决的是通用性的事,不要试图让通用性方案继续去解决不通用的事。
一些缺失类的说明:
ResponseCode:
这个类其实就是一个普通的枚举类,没说明是因为每个人的项目都不一样,应该是一看就明白的东西吧…
@AllArgsConstructor@Getterpublic enum ResponseCode { SYSTEM_QUERY_CONDITION_ERROR(10004, "查询条件解析失败"), SYSTEM_QUERY_CONDITION_NOT_SUPPORT(10005,"不支持的查询条件"), .... }Result:
通用的返回给前端的数据解构:
@Datapublic class Result<T> { private Integer code; private String message; private T data; .... public static <T> Result<T> error(Integer responseCode, String message, T data) { Result<T> result = new Result<>(); result.setData(data); result.setCode(responseCode); result.setMessage(message); return result; }}查询成本概览:
- OR 与深层嵌套:会显著增加
SQL复杂度与优化器成本,容易触发全表扫描或与预期不一样的执行方式。 - 模糊(
LIKE '%x'/NOT LIKE '%x'):大表基本无法走索引,应尽量限制字段或条件数量。 - IN 大集合:集合太大会拖慢解析与执行,建议限制长度或落表再 join。
- Join + count:分页时
count(*)与 join 组合开销很大,noPage场景已经通过阈值限制。 - 排序字段:尽量使用索引列作为默认排序,避免 join 后排序放大代价。
- 分页相关:通过
MAX_UNPAGED_SIZE控制返回量,避免一次拉取全部数据。
不过我并没有定义MAX_GROUP_DEPTH,像模糊、IN大集合之类的也不好在代码层面上限制。
我觉得主要还是代码审查的问题,不管是新人还是老人,代码提交前,都应该给LLM,或者给公司内部专门做代码审查的LLM检查,如果可以的话,再集中做人工代码评审。
另外要区分导出与不分页的差别,这个工具本质上是一个查询工具而不是导出工具,导出工具应该自己去实现SQL,不要依赖这个工具,这也是一直强调的工具边界问题。
17. 单元测试
QueryFilterApplier:
class QueryFilterApplierTest {
@Test void apply_buildsExpectedSqlSegment() { // 正常路径:EQ + BETWEEN 应转换为 SQL 片段。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "status", QueryFieldSpec.of("status", EnumSet.of(FilterOp.EQ)), "createdTime", QueryFieldSpec.of("created_time", EnumSet.of(FilterOp.BETWEEN)) );
FilterCondition status = new FilterCondition();203 collapsed lines
status.setField("status"); status.setOp(FilterOp.EQ); status.setValue("pending");
FilterCondition created = new FilterCondition(); created.setField("createdTime"); created.setOp(FilterOp.BETWEEN); created.setValue(List.of("2026-01-01", "2026-01-31"));
QueryFilterApplier.apply(wrapper, List.of(status, created), specs);
String sql = wrapper.getCustomSqlSegment(); assertTrue(sql.contains("status")); assertTrue(sql.contains("created_time")); assertTrue(sql.toUpperCase().contains("BETWEEN")); }
@Test void apply_rejectsUnsupportedOp() { // 不在字段规格中的操作符应被拒绝。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "status", QueryFieldSpec.of("status", EnumSet.of(FilterOp.EQ)) );
FilterCondition status = new FilterCondition(); status.setField("status"); status.setOp(FilterOp.LIKE); status.setValue("pending");
assertThrows(QueryException.class, () -> QueryFilterApplier.apply(wrapper, List.of(status), specs)); }
@Test void apply_rejectsFiltersAndGroupConflict() { // filters 与 group 不能同时存在。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "status", QueryFieldSpec.of("status", EnumSet.of(FilterOp.EQ)) );
QueryFilters query = new QueryFilters(); query.setFilters(List.of(filter("status", FilterOp.EQ, "pending"))); query.setGroup(new QueryGroup());
assertThrows(QueryException.class, () -> QueryFilterApplier.apply(wrapper, query, specs)); }
@Test void apply_rejectsTooManyFilters() { // 超过最大 filters 数量应被拒绝。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "status", QueryFieldSpec.of("status", EnumSet.of(FilterOp.EQ)) );
List<FilterCondition> filters = new ArrayList<>(); for (int i = 0; i < QueryFilters.MAX_FILTERS + 1; i++) { filters.add(filter("status", FilterOp.EQ, "pending")); }
assertThrows(QueryException.class, () -> QueryFilterApplier.apply(wrapper, filters, specs)); }
@Test void apply_rejectsTooManyGroups() { // 超过最大 group 数量应被拒绝。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "status", QueryFieldSpec.of("status", EnumSet.of(FilterOp.EQ)) );
QueryGroup group = new QueryGroup(); List<QueryGroup> groups = new ArrayList<>(); for (int i = 0; i < QueryGroup.MAX_GROUPS + 1; i++) { groups.add(new QueryGroup()); } group.setGroups(groups);
QueryFilters query = new QueryFilters(); query.setGroup(group);
assertThrows(QueryException.class, () -> QueryFilterApplier.apply(wrapper, query, specs)); }
@Test void apply_rejectsEmptyIn() { // 严格模式下 IN 不能为空。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "id", QueryFieldSpec.of("id", EnumSet.of(FilterOp.IN)) );
FilterCondition condition = filter("id", FilterOp.IN, List.of()); assertThrows(QueryException.class, () -> QueryFilterApplier.apply(wrapper, List.of(condition), specs)); }
@Test void apply_rejectsEmptyNotIn() { // 严格模式下 NOT_IN 不能为空。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "id", QueryFieldSpec.of("id", EnumSet.of(FilterOp.NOT_IN)) );
FilterCondition condition = filter("id", FilterOp.NOT_IN, List.of()); assertThrows(QueryException.class, () -> QueryFilterApplier.apply(wrapper, List.of(condition), specs)); }
@Test void apply_rejectsInvalidBetween() { // BETWEEN 必须提供两个值。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "createdTime", QueryFieldSpec.of("created_time", EnumSet.of(FilterOp.BETWEEN)) );
FilterCondition condition = filter("createdTime", FilterOp.BETWEEN, List.of("only-one")); assertThrows(QueryException.class, () -> QueryFilterApplier.apply(wrapper, List.of(condition), specs)); }
@Test void apply_wrapsAutoLikeValue() { // autoLike 需要包裹为 %...%。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "name", QueryFieldSpec.of("name", EnumSet.of(FilterOp.LIKE), true) );
FilterCondition condition = filter("name", FilterOp.LIKE, "neo"); QueryFilterApplier.apply(wrapper, List.of(condition), specs);
boolean matched = wrapper.getParamNameValuePairs().values().stream() .anyMatch(value -> "%neo%".equals(value)); assertTrue(matched); }
@Test void apply_supportsNotLikeLeftRight() { // NOT_LIKE_LEFT/RIGHT 需生成 NOT LIKE 条件。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "name", QueryFieldSpec.of("name", EnumSet.of(FilterOp.NOT_LIKE_LEFT, FilterOp.NOT_LIKE_RIGHT)) );
QueryFilterApplier.apply(wrapper, List.of( filter("name", FilterOp.NOT_LIKE_LEFT, "neo"), filter("name", FilterOp.NOT_LIKE_RIGHT, "neo") ), specs);
String sql = wrapper.getCustomSqlSegment(); assertTrue(sql.toUpperCase().contains("NOT LIKE")); }
@Test void apply_buildsOrGroupWithNestedParentheses() { // OR 组应保留每个子组的括号嵌套。 QueryWrapper<Object> wrapper = new QueryWrapper<>(); Map<String, QueryFieldSpec> specs = Map.of( "processId", QueryFieldSpec.of("process_id", EnumSet.of(FilterOp.EQ)), "status", QueryFieldSpec.of("status", EnumSet.of(FilterOp.EQ)), "applicantId", QueryFieldSpec.of("applicant_id", EnumSet.of(FilterOp.EQ)), "createdTime", QueryFieldSpec.of("created_time", EnumSet.of(FilterOp.GE)) );
QueryGroup group1 = new QueryGroup(); group1.setLogic(QueryLogic.AND); group1.setFilters(List.of( filter("processId", FilterOp.EQ, 1), filter("status", FilterOp.EQ, "pending") ));
QueryGroup group2 = new QueryGroup(); group2.setLogic(QueryLogic.AND); group2.setFilters(List.of( filter("applicantId", FilterOp.EQ, 2), filter("createdTime", FilterOp.GE, "2026-01-01") ));
QueryGroup root = new QueryGroup(); root.setLogic(QueryLogic.OR); root.setGroups(List.of(group1, group2));
QueryFilters query = new QueryFilters(); query.setGroup(root);
QueryFilterApplier.apply(wrapper, query, specs);
String sql = wrapper.getCustomSqlSegment(); assertTrue(sql.matches("(?is).*\\(.*process_id.*and.*status.*\\)\\s*or\\s*\\(.*applicant_id.*and.*created_time.*\\).*")); }
private FilterCondition filter(String field, FilterOp op, Object value) { FilterCondition condition = new FilterCondition(); condition.setField(field); condition.setOp(op); condition.setValue(value); return condition; }}QueryPageBuilder:
class QueryPageBuilderTest {
@Test void buildPage_resolvesOrderColumns() { // 排序字段应通过白名单映射。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(1); request.setSize(10); request.setOrders(List.of( BasePageRequest.BaseOrderItem.desc("createdTime"), BasePageRequest.BaseOrderItem.asc("status") ));117 collapsed lines
IPage<Object> page = QueryPageBuilder.buildPage(request, Map.of( "createdTime", "created_time", "status", "status" ));
List<OrderItem> orders = page.orders(); assertEquals(2, orders.size()); assertEquals("created_time", orders.get(0).getColumn()); assertEquals("status", orders.get(1).getColumn()); }
@Test void buildPage_rejectsUnknownOrderField() { // 未知排序字段应被拒绝。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(1); request.setSize(10); request.setOrders(List.of(BasePageRequest.BaseOrderItem.asc("unknown")));
assertThrows(QueryException.class, () -> QueryPageBuilder.buildPage(request, Map.of("status", "status"))); }
@Test void buildPage_unpagedWhenNoPageTrue() { // noPage=true 应触发不分页。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(2); request.setSize(10); request.setNoPage(true);
IPage<Object> page = QueryPageBuilder.buildPage(request, Map.of("id", "id"));
assertEquals(1L, page.getCurrent()); assertEquals(BasePageRequest.MAX_UNPAGED_SIZE, page.getSize()); assertFalse(page.isSearchCount()); }
@Test void buildPage_unpagedWhenPageIsNegative() { // page < 0 应触发不分页。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(-1); request.setSize(10);
IPage<Object> page = QueryPageBuilder.buildPage(request, Map.of("id", "id"));
assertEquals(1L, page.getCurrent()); assertEquals(BasePageRequest.MAX_UNPAGED_SIZE, page.getSize()); assertFalse(page.isSearchCount()); }
@Test void buildPage_allowsMaxUnpagedSizeInPagedMode() { // 分页模式下允许 MAX_UNPAGED_SIZE。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(1); request.setSize(BasePageRequest.MAX_UNPAGED_SIZE);
IPage<Object> page = QueryPageBuilder.buildPage(request, Map.of("id", "id"));
assertEquals(1L, page.getCurrent()); assertEquals(BasePageRequest.MAX_UNPAGED_SIZE, page.getSize()); assertTrue(page.isSearchCount()); }
@Test void buildPage_skipsNaturalOrder() { // NATURAL 方向应被忽略。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(1); request.setSize(10); request.setOrders(List.of(new BasePageRequest.BaseOrderItem("status", BasePageRequest.OrderDirection.NATURAL)));
IPage<Object> page = QueryPageBuilder.buildPage(request, Map.of("status", "status")); assertTrue(page.orders().isEmpty()); }
@Test void buildPage_rejectsEmptyOrderMapping() { // 排序白名单为空时应拒绝排序。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(1); request.setSize(10); request.setOrders(List.of(BasePageRequest.BaseOrderItem.asc("status")));
assertThrows(QueryException.class, () -> QueryPageBuilder.buildPage(request, Map.of())); }
@Test void buildPage_rejectsNullDirection() { // 排序方向为 null 应被拒绝。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(1); request.setSize(10); BasePageRequest.BaseOrderItem item = new BasePageRequest.BaseOrderItem("status", BasePageRequest.OrderDirection.ASC); item.setDirection((BasePageRequest.OrderDirection) null); request.setOrders(List.of(item));
assertThrows(QueryException.class, () -> QueryPageBuilder.buildPage(request, Map.of("status", "status"))); }
@Test void buildPage_rejectsTooManyOrders() { // 超过最大排序数量应被拒绝。 BasePageRequest<QueryFilters> request = new BasePageRequest<>(); request.setPage(1); request.setSize(10); List<BasePageRequest.BaseOrderItem> orders = new ArrayList<>(); for (int i = 0; i < BasePageRequest.MAX_ORDERS + 1; i++) { orders.add(BasePageRequest.BaseOrderItem.asc("status")); } request.setOrders(orders);
assertThrows(QueryException.class, () -> QueryPageBuilder.buildPage(request, Map.of("status", "status"))); }}QueryValueConverters
class QueryValueConvertersTest {
private enum DemoStatus { PENDING, DONE }
@Test void toLong_convertsNumberAndString() { // toLong 应处理数字、裁剪字符串,空串返回 null。 Function<Object, Object> converter = QueryValueConverters.toLong(); assertEquals(10L, converter.apply(10));123 collapsed lines
assertEquals(12L, converter.apply(" 12 ")); assertNull(converter.apply(" ")); }
@Test void toLong_rejectsUnsupportedType() { // toLong 应拒绝不支持的类型。 Function<Object, Object> converter = QueryValueConverters.toLong(); assertThrows(IllegalArgumentException.class, () -> converter.apply(new Object())); }
@Test void toInteger_convertsNumberAndString() { // toInteger 应处理数字、裁剪字符串,空串返回 null。 Function<Object, Object> converter = QueryValueConverters.toInteger(); assertEquals(7, converter.apply(7L)); assertEquals(9, converter.apply(" 9 ")); assertNull(converter.apply("")); }
@Test void toBigDecimal_convertsNumbersAndStrings() { // toBigDecimal 应保持整数类精度并支持字符串小数。 Function<Object, Object> converter = QueryValueConverters.toBigDecimal(); assertEquals(new BigDecimal("123"), converter.apply(new BigInteger("123"))); assertEquals(new BigDecimal("42"), converter.apply(42L)); assertEquals(new BigDecimal("12.34"), converter.apply("12.34")); }
@Test void toBigDecimal_rejectsUnsupportedType() { // toBigDecimal 应拒绝不支持的类型。 Function<Object, Object> converter = QueryValueConverters.toBigDecimal(); assertThrows(IllegalArgumentException.class, () -> converter.apply(OffsetDateTime.now())); }
@Test void toBoolean_convertsStringAndHandlesEmpty() { // toBoolean 应解析字符串,空串返回 null。 Function<Object, Object> converter = QueryValueConverters.toBoolean(); assertEquals(Boolean.TRUE, converter.apply(" true ")); assertNull(converter.apply(" ")); }
@Test void toBoolean_rejectsUnsupportedType() { // toBoolean 应拒绝不支持的类型。 Function<Object, Object> converter = QueryValueConverters.toBoolean(); assertThrows(IllegalArgumentException.class, () -> converter.apply(1)); }
@Test void toOffsetDateTime_convertsTimestamp() { // toOffsetDateTime 应将时间戳转换为 OffsetDateTime。 Function<Object, Object> converter = QueryValueConverters.toOffsetDateTime(); long timestamp = 1700000000000L; OffsetDateTime result = (OffsetDateTime) converter.apply(timestamp); assertEquals(Instant.ofEpochMilli(timestamp), result.toInstant()); }
@Test void toOffsetDateTime_convertsTimestampString() { // toOffsetDateTime 应将纯数字字符串视为时间戳。 Function<Object, Object> converter = QueryValueConverters.toOffsetDateTime(); long timestamp = 1700000000000L; OffsetDateTime result = (OffsetDateTime) converter.apply(String.valueOf(timestamp)); assertEquals(Instant.ofEpochMilli(timestamp), result.toInstant()); }
@Test void toOffsetDateTime_convertsFormattedDateTime() { // toOffsetDateTime 应解析默认的日期时间格式。 Function<Object, Object> converter = QueryValueConverters.toOffsetDateTime(); String text = "2024-01-02 03:04:05"; LocalDateTime ldt = LocalDateTime.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); OffsetDateTime expected = ldt.atZone(ZoneId.systemDefault()).toOffsetDateTime(); assertEquals(expected, converter.apply(text)); }
@Test void toOffsetDateTime_convertsIsoOffsetDateTime() { // toOffsetDateTime 应解析 ISO 带偏移的时间串。 Function<Object, Object> converter = QueryValueConverters.toOffsetDateTime(); String text = "2024-01-02T03:04:05+08:00"; assertEquals(OffsetDateTime.parse(text), converter.apply(text)); }
@Test void toOffsetDateTime_rejectsUnsupportedType() { // toOffsetDateTime 应拒绝不支持的类型。 Function<Object, Object> converter = QueryValueConverters.toOffsetDateTime(); assertThrows(IllegalArgumentException.class, () -> converter.apply(new Object())); }
@Test void enumByName_handlesStringAndEnum() { // enumByName 应支持枚举实例与字符串名称。 Function<Object, Object> converter = QueryValueConverters.enumByName(DemoStatus.class); assertEquals(DemoStatus.PENDING, converter.apply("PENDING")); assertEquals(DemoStatus.DONE, converter.apply(DemoStatus.DONE)); assertNull(converter.apply(" ")); }
@Test void enumByName_rejectsUnsupportedType() { // enumByName 应拒绝不支持的类型。 Function<Object, Object> converter = QueryValueConverters.enumByName(DemoStatus.class); assertThrows(IllegalArgumentException.class, () -> converter.apply(1)); }
@Test void enumByResolver_usesResolverAndHandlesEmpty() { // enumByResolver 应将字符串交给 resolver 处理。 Function<Object, Object> converter = QueryValueConverters.enumByResolver( value -> "PENDING".equals(value) ? DemoStatus.PENDING : DemoStatus.DONE ); assertEquals(DemoStatus.PENDING, converter.apply("PENDING")); assertNull(converter.apply(" ")); }
@Test void enumByResolver_rejectsUnsupportedType() { // enumByResolver 应拒绝不支持的类型。 Function<Object, Object> converter = QueryValueConverters.enumByResolver(value -> DemoStatus.PENDING); assertThrows(IllegalArgumentException.class, () -> converter.apply(123)); }}