如何在 native JPA query 中使用对象作为过滤器?
结论
QuestionCategoryFilter 不应继承 QueryParameterJavaObjectType,也不需要改为继承其他 Hibernate JavaType。
报错来自 SQL 中的这段判断:
:filter IS NULL
这段 SQL 会让 Hibernate 尝试把整个 QuestionCategoryFilter 绑定为一个 JDBC 参数。普通 POJO 没有对应的 JDBC 类型,Hibernate 无法推断映射,于是抛出:
No JDBC mapping could be inferred for parameter
可以采用两种处理方式:
- 保留普通 POJO,通过 SpEL 读取其中的字段,不在 SQL 中直接使用整个对象。
- 由 Service 将过滤对象拆成标量参数,再传给 Repository。对于 native query,这种方式更容易调试,边界也更清楚。
过滤对象可以和 username 同时出现在方法参数中。只要不把整个过滤对象绑定到 SQL 占位符,就不会有问题。
为什么不应继承 QueryParameterJavaObjectType
QueryParameterJavaObjectType 属于 Hibernate 类型系统,用于描述查询参数与 JDBC 类型之间的映射关系,并不是业务 DTO 或查询条件类的通用父类。
让过滤器继承它会引入一些问题:
- 业务模型会依赖 Hibernate 的内部实现。
- 升级 Hibernate 时可能遇到兼容性问题。
- Jackson、springdoc 等组件可能识别父类公开的属性和方法。
- Swagger 可能把继承而来的属性当成 API 模型的一部分。
- 即便查询暂时可以执行,依然没有定义如何把这个 POJO 转换为单个 JDBC 值。
也没有更简单、适合过滤器继承的 JavaType。这个过滤器包含四个独立的值,不适合映射为一个 JDBC 参数。
方案一:保留过滤对象,只绑定对象中的字段
过滤器仍可作为 Repository 的方法参数,用来集中保存查询条件,但 SQL 只能引用其中的字段。
先把过滤器恢复成普通类:
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
public class QuestionCategoryFilter {
private String name;
private Boolean active;
private Integer orderNumber;
private String answerTypeName;
}
然后删除 :filter IS NULL:
@Transactional(readOnly = true)
@Query(nativeQuery = true, value = """
SELECT
qc.id,
qc.name,
qc.active,
qc.order_number AS orderNumber,
qc.answer_type_id AS answerTypeId,
at.name AS answerTypeName,
at.max_value AS maxValue,
q.id AS questionId,
q.question,
q.active AS questionActive,
q.order_number AS questionOrderNumber,
ai.num_answer AS numAnswer,
a.answer_time AS answerTime,
a.active AS answerActive,
u.name AS username,
u.ldap_id AS ldapId,
u.active AS userActive
FROM QUESTION_CATEGORIES qc
INNER JOIN ANSWER_TYPES at ON at.id = qc.answer_type_id
INNER JOIN QUESTIONS q ON q.category_id = qc.id
LEFT JOIN ANSWER_ITEMS ai ON ai.question_id = q.id
LEFT JOIN ANSWERS a ON a.id = ai.answer_id
LEFT JOIN USERS u
ON u.id = a.user_id
AND u.ldap_id = :username
WHERE q.active = true
AND (
:#{#filter.getName()} IS NULL
OR LOWER(qc.name) LIKE CONCAT(
'%',
LOWER(:#{#filter.getName()}),
'%'
)
)
AND (
:#{#filter.getActive()} IS NULL
OR qc.active = :#{#filter.getActive()}
)
AND (
:#{#filter.getOrderNumber()} IS NULL
OR qc.order_number = :#{#filter.getOrderNumber()}
)
AND (
:#{#filter.getAnswerTypeName()} IS NULL
OR at.name = :#{#filter.getAnswerTypeName()}
)
ORDER BY qc.order_number
""")
List<QuestionCategoryDAO> findAllResponsesByFilterAndUser(
@Param("filter") QuestionCategoryFilter filter,
@Param("username") String username
);
这里的 filter 只作为 Spring Data SpEL 的求值对象。Spring Data 最终绑定的是 String、Boolean、Integer 等字段值,不会绑定 QuestionCategoryFilter 对象本身。
如何处理空过滤器
如果调用方可以传入 null,表达式 #filter.getName() 将无法求值。可以在 Service 层将 null 替换为一个所有字段均为 null 的过滤器:
@Service
@RequiredArgsConstructor
public class QuestionCategoryService {
private final QuestionCategoryRepository repository;
@Transactional(readOnly = true)
public List<QuestionCategoryDAO> findAll(
QuestionCategoryFilter filter,
String username) {
QuestionCategoryFilter effectiveFilter =
filter != null
? filter
: new QuestionCategoryFilter();
return repository.findAllResponsesByFilterAndUser(
effectiveFilter,
username
);
}
}
四个字段都为 null 时,所有可选条件都会成立,相当于不应用过滤条件。
原查询中的运算符优先级问题
原条件写成了:
WHERE q.active = true
AND :filter IS NULL OR (...)
SQL 中 AND 的优先级高于 OR,因此这段条件实际等价于:
WHERE (q.active = true AND :filter IS NULL)
OR (...)
只要后面的过滤条件成立,q.active = true 就可能失去限制作用。
如果确实需要保留整体分支,应加上括号:
WHERE q.active = true
AND (
:someFlag = true
OR (...)
)
当前场景可以直接约定空字段表示忽略对应条件,无需再判断整个 :filter 是否为 NULL。
方案二:调用 Repository 前拆成标量参数
对于 native query,这种方式的边界最清楚。Controller 和 Service 继续使用过滤对象,Repository 只接收数据库能够绑定的标量值。
Repository:
@Transactional(readOnly = true)
@Query(nativeQuery = true, value = """
SELECT
qc.id,
qc.name,
qc.active,
qc.order_number AS orderNumber,
qc.answer_type_id AS answerTypeId,
at.name AS answerTypeName
FROM QUESTION_CATEGORIES qc
INNER JOIN ANSWER_TYPES at ON at.id = qc.answer_type_id
INNER JOIN QUESTIONS q ON q.category_id = qc.id
WHERE q.active = true
AND (
:name IS NULL
OR LOWER(qc.name) LIKE CONCAT('%', LOWER(:name), '%')
)
AND (:active IS NULL OR qc.active = :active)
AND (:orderNumber IS NULL OR qc.order_number = :orderNumber)
AND (:answerTypeName IS NULL OR at.name = :answerTypeName)
ORDER BY qc.order_number
""")
List<QuestionCategoryDAO> findAllResponsesByFilterAndUser(
@Param("name") String name,
@Param("active") Boolean active,
@Param("orderNumber") Integer orderNumber,
@Param("answerTypeName") String answerTypeName,
@Param("username") String username
);
Service 负责解包:
@Transactional(readOnly = true)
public List<QuestionCategoryDAO> findAll(
QuestionCategoryFilter filter,
String username) {
QuestionCategoryFilter effectiveFilter =
filter != null
? filter
: new QuestionCategoryFilter();
return repository.findAllResponsesByFilterAndUser(
effectiveFilter.getName(),
effectiveFilter.getActive(),
effectiveFilter.getOrderNumber(),
effectiveFilter.getAnswerTypeName(),
username
);
}
Repository 方法的参数会多一些,但调用方仍然只需处理一个过滤对象。数据库绑定也更直接,不依赖 Spring Data 展开 SpEL 表达式。
如果 Service 中出现了重复的解包代码,可以提取为私有辅助方法,或者通过自定义 Repository 统一封装。
是否需要单独创建 API Filter
是否拆分 API DTO 和查询过滤器,要看两层模型是否存在实际差异。
如果 HTTP 请求与查询条件使用相同的字段、类型和含义,可以直接共用普通的 QuestionCategoryFilter,没必要复制一份内容相同的类。
遇到以下情况时,更适合拆分:
- API 参数名与数据库查询条件不同。
- API 需要使用
@Size、@Pattern等校验注解。 - API 接收字符串,查询层需要枚举、日期或其他转换后的类型。
- 查询模型中包含不应向客户端公开的字段。
- API 版本需要独立演进。
- 同一个查询过滤器还会用于非 HTTP 场景。
例如:
@Data
public class QuestionCategoryFilterRequest {
private String name;
private Boolean active;
private Integer orderNumber;
private String answerTypeName;
}
Service 可以将它转换成内部查询条件,也可以直接拆成 Repository 所需的标量参数。无论是否拆分,API DTO 都不应继承 Hibernate 类型类。
处理 Swagger 字段膨胀
Swagger 中出现大量 Hibernate 属性,是过滤器继承框架类型后造成的模型污染。springdoc 分析类结构时,可能把父类公开的属性、访问器或构造信息加入 OpenAPI Schema。
直接移除继承即可:
public class QuestionCategoryFilter {
// 仅保留业务字段
}
相比逐个添加 @JsonIgnore 或配置 Swagger 排除父类属性,这种处理更简单。过滤器恢复为普通 POJO 后,Swagger 通常只会描述四个业务字段。
Lombok 可以继续使用,但要确认 @Data、@Builder、@AllArgsConstructor 生成的结构能被当前 springdoc 版本正确识别。调整构造函数或许能暂时缩小 Swagger Schema,但不应因此保留对 QueryParameterJavaObjectType 的继承。
参数包装对象和独立参数能否混用
可以。下面的方法签名没有问题:
findAllResponsesByFilterAndUser(
QuestionCategoryFilter filter,
String username
);
username 可以作为普通命名参数绑定,filter 则作为 SpEL 求值上下文。两者同时存在不会形成不受支持的参数组合。
绑定时需要遵守以下规则:
:username可以直接绑定,因为String有明确的 JDBC 映射。:#{#filter.getName()}可以绑定,因为表达式结果是String。:#{#filter.getActive()}可以绑定,因为表达式结果是Boolean。:filter不能直接绑定,因为普通过滤器 POJO 不是单个 JDBC 值。
如果过滤条件继续增加,静态 SQL 中大量使用 :value IS NULL OR ... 会越来越难维护。届时可以编写自定义 Repository,通过 EntityManager 动态拼接参数化条件。如果查询不需要使用原生 SQL,也可以改用 Specification、Criteria API 或 Querydsl。当前只有四个条件,使用普通 POJO 配合 SpEL,或者由 Service 解包成标量参数,都足够清楚。
备注:内容仅供参考。
