7149 字
36 分钟
构建一个安全且带有Trace的MyBatis-Plus通用查询模块

0. 前言#

其实在有LLM的时代,做一个通用查询工具模块似乎比较多余。

出于对代码掌控以及编写方式的一些个人的要求,我将查询分为两类,一类是比较简单的单表以及复杂度不高的多表查询,另一类是需要用到.xml的复杂查询。

前者我觉得如果能遵循某一类范式作为Context,无论是复用者还是LLM,都能很好地遵循一个规则,在这个规则下写出更好审查以及追踪的代码,这就是使用通用查询工具的意义。

而后者本身在业务中占比不多,需要用到的时候肯定也会充分设计,在此基础上可以零星地用到通用查询的工具(比如说统计与传入字段约束),核心功能交由.xml去实现,我觉得是比较合理的实现。

注:本文不考虑纯粹的SQL拼接,完全依靠安全的MyBatis-PlusQueryWrapper拼接。

1. 拟定通用查询数据结构:BasePageRequest#

一般而言,前端想要做一个查询,无非就是:

  • 排序方式
  • 字段+条件+查询值
  • 页码
  • 是否分页
  • 分页大小

出于安全角度考虑,我们还得想到:

  • 最大分页大小限制
  • 最大排序字段限制(避免过于复杂的查询)

对于这种通用的查询数据结构,字段+条件+查询值必然需要声明约束,这个约束应该放在哪先不管,不过很明显我们可以定义一个泛型先顶着,于是便有了下面的BasePageRequest类:

@Data
public 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_NULLIS_NOT_NULL之类的无值字段查询。

@Data
public 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#

其实这个filtersgroup限制在2010其实只是遵循JSR-303 约束,按照自己业务情况来吧。

@Data
public 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) 之类复杂结构。的

分离的好处:简单查询不需要树结构,复杂查询才进入递归,避免所有请求都背负树模型成本。

@Data
public 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上。

这个类主要实现以下功能:

  1. 校验字段是否在白名单内。
  2. between/in/like这种并非单值的情况处理。
  3. 普通单值条件处理
  4. group条件处理
  5. 使用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#

前面抛出了这么个异常但没定义,这里补上,其实就是一个常规的异常而已。

@Getter
public 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;
@Component
public 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
@Slf4j
public 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里面写主要是考虑到以下因素:

  1. 其实在哪里写都是要写的,放DTO里面的话,我们还要做个Aspect,也没有省多少功夫的同时还引入多一个AOP,并没有降低工作量。
  2. 同一个DTO可能会碰到以下场景:在A 接口允许模糊查,在 B 接口只允许精确查,难不成要为了一个字段又创建一个DTO
  3. 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
@RequiredArgsConstructor
public 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)
@Documented
public @interface QueryTrace {
}

实现:

@Aspect
@Component
@RequiredArgsConstructor
public 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
@RequiredArgsConstructor
public 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. 总结与注意事项#

流程:#

  1. Controller 接收 BasePageRequest<QueryFilters>
  2. QueryFilterApplier 校验并写入 QueryWrapper
  3. QueryPageBuilder 生成 IPage(分页 + 排序)。
  4. Mapper 执行 selectPage
  5. 相对简单的多表查询采用MyBatis-Plus-Join插件+Alias别名映射方案。
  6. Service层使用@QueryTrace注解收集信息。
  7. 若字段/查询条件非法,抛出 QueryException,由 QueryExceptionHandler 统一返回错误并打tag。
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Controller │ ──▶ │ QueryFilterApplier│ ──▶ │ QueryWrapper │
└─────────────┘ └──────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ QueryPage │ ────────────────────────────▶│ Mapper.selectPage│
│ Builder │ └─────────────────┘
└─────────────┘

所有通用性方案都会有自己的边界,通用性方案解决的是通用性的事,不要试图让通用性方案继续去解决不通用的事。

一些缺失类的说明:#

ResponseCode:#

这个类其实就是一个普通的枚举类,没说明是因为每个人的项目都不一样,应该是一看就明白的东西吧…

@AllArgsConstructor
@Getter
public enum ResponseCode {
SYSTEM_QUERY_CONDITION_ERROR(10004, "查询条件解析失败"),
SYSTEM_QUERY_CONDITION_NOT_SUPPORT(10005,"不支持的查询条件"),
....
}
Result:#

通用的返回给前端的数据解构:

@Data
public 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));
}
}
构建一个安全且带有Trace的MyBatis-Plus通用查询模块
https://blog.astro777.cfd/posts/java/building-a-common-query-architecture/
作者
ASTRO
发布于
2026-01-26
许可协议
CC BY-NC-SA 4.0