PAYATHON 2026

如何在 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

可以采用两种处理方式:

  1. 保留普通 POJO,通过 SpEL 读取其中的字段,不在 SQL 中直接使用整个对象。
  2. 由 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 解包成标量参数,都足够清楚。

备注:内容仅供参考。