与 JPARepository 一起使用的 QueryDSL 谓词,其中字段是 JSON 使用 AttributeConverter 转换为列表 <Object> 的字符串

QueryDSL Predicate for use with JPARepository where field is a JSON String converted using an AttributeConverter to a List<Object>

我有一个 JPA 实体(终端),它使用 AttributeConverter 将数据库字符串转换为对象列表 (ProgrmRegistration)。转换器仅使用 JSON ObjectMapper 将 JSON String 转换为 POJO 对象。

实体对象

@Entity
@Data
public class Terminal {

    @Id
    private String terminalId;

    @NotEmpty
    @Convert(converter = ProgramRegistrationConverter.class)
    private List<ProgramRegistration> programRegistrations;

    @Data
    public static class ProgramRegistration {
        private String program;
        private boolean online;
    }
}

终端使用以下 JPA AttributeConverter 将对象序列化为 JSON

JPA 属性转换器

public class ProgramRegistrationConverter implements AttributeConverter<List<Terminal.ProgramRegistration>, String> {

    private final ObjectMapper objectMapper;
    private final CollectionType programRegistrationCollectionType;

    public ProgramRegistrationConverter() {
        this.objectMapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
        this.programRegistrationCollectionType = 
              objectMapper.getTypeFactory().constructCollectionType(List.class, Terminal.ProgramRegistration.class);
    }

    @Override
    public String convertToDatabaseColumn(List<Terminal.ProgramRegistration> attribute) {
        if (attribute == null) {
            return null;
        }
        String json = null;
        try {
            json = objectMapper.writeValueAsString(attribute);
        } catch (final JsonProcessingException e) {
            LOG.error("JSON writing error", e);
        }
        return json;
    }

    @Override
    public List<Terminal.ProgramRegistration> convertToEntityAttribute(String dbData) {
        if (dbData == null) {
            return Collections.emptyList();
        }
        List<Terminal.ProgramRegistration> list = null;
        try {
            list = objectMapper.readValue(dbData, programRegistrationCollectionType);
        } catch (final IOException e) {
            LOG.error("JSON reading error", e);
        }
        return list;
    }

}

我正在使用 Spring Boot 和 JPARepository 从数据库中获取终端结果页面。 为了过滤结果,我使用 BooleanExpression 作为谓词。对于实体上的所有过滤器值,它运行良好,但是从 JSON 字符串转换的对象列表不允许我轻松编写一个表达式来过滤列表中的对象。

REST API 正在尝试使用 QueryDSL

过滤实体对象
@GetMapping(path = "/filtered/page", produces = MediaType.APPLICATION_JSON_VALUE)
public Page<Terminal> findFilteredWithPage(
    @RequestParam(required = false) String terminalId,
    @RequestParam(required = false) String programName,
    @PageableDefault(size = 20) @SortDefault.SortDefaults({ @SortDefault(sort = "terminalId") }) Pageable p) {

    BooleanBuilder builder = new BooleanBuilder();
    if (StringUtils.isNotBlank(terminalId))
       builder.and(QTerminal.terminal.terminalId.upper()
                  .contains(StringUtils.upperCase(terminalId)));

    // TODO: Figure out how to use QueryDsl to get the converted List as a predicate
    // The code below to find the programRegistrations does not allow a call to any(), 
    // expects a CollectionExpression or a SubqueryExpression for calls to eqAny() or in()
    
    if (StringUtils.isNotBlank(program)) 
       builder.and(QTerminal.terminal.programRegistrations.any().name()
                  .contains(StringUtils.upperCase(programName)));

    return terminalRepository.findAll(builder.getValue(), p);
}

我想要获得任何具有 ProgramRegistration 对象且程序名称等于传递到 REST 服务的参数的终端。

我一直试图让 CollectionExpression 或 SubQueryExpression 工作但没有成功,因为它们似乎都想在两个 Entity 对象之间执行连接。我不知道如何创建路径和查询,以便它可以遍历 programRegistrations 检查“程序”字段是否匹配。我没有要加入的 QProgamRegistration 对象,因为它只是一个 POJO 列表。

如何让谓词只匹配具有我正在搜索的名称的程序的终端?

这是行不通的线路:

builder.and(QTerminal.terminal.programRegistrations.any().name() .contains(StringUtils.upperCase(programName)));

AttributeConverters 在 Querydsl 中存在问题,因为它们在 JPQL(JPA 的查询语言)本身中存在问题。目前还不清楚属性的底层 查询类型 是什么,以及参数是否应该是该查询类型的基本类型,或者应该使用转换进行转换。这种转换虽然看起来合乎逻辑,但并未在 JPA 规范中定义。因此需要使用 query type 的基本类型,这会导致新的困难,因为 Querydsl 无法知道它需要的类型。它只知道属性的 Java 类型。

解决方法是通过使用 @QueryType(PropertyType.STRING) 注释字段来强制该字段生成 StringPath。虽然这解决了某些查询的问题,但在其他情况下您将 运行 遇到不同的问题。有关详细信息,请参阅 this 主题。

尽管以下所需的 QueryDsl 看起来应该可以工作

QTerminal.terminal.programRegistrations.any().name().contains(programName);

实际上,JPA 永远无法将其转换成在 SQL 方面有意义的东西。 JPA 可以将其转换成的唯一 SQL 可能如下所示:

SELECT t.terminal_id FROM terminal t where t.terminal_id LIKE '%00%' and t.program_registrations like '%"program":"MY_PROGRAM_NAME"%'; 

这在这个用例中可以工作,但在语义上是错误的,因此它不应该工作是正确的。 尝试使用结构化查询语言select非结构化数据毫无意义

唯一的解决办法是将数据作为数据库搜索条件的字符,查询完成后将其作为对象列表,然后对Java中的行进行过滤。虽然这使得分页功能相当无用。

一种可能的解决方案是使用用于数据库搜索条件的列的辅助只读字符串版本,它不会被 AttributeConverter 转换为 JSON。

@JsonIgnore
@Column(name = "programRegistrations", insertable = false, updatable = false)
private String programRegistrationsStr;

真正的解决方案是当你想对非结构化数据进行结构化查询时,不要使用非结构化数据因此,将数据转换为原生支持JSON的数据库在 DDL 中正确查询或建模数据。

简短回答:@QueryType 属性谓词中使用的参数必须在字符串类型属性的另一个谓词中使用。

这是此线程中描述的一个众所周知的问题:https://github.com/querydsl/querydsl/issues/2652


我只是想分享我对这个错误的经验。

型号

我有一个像

这样的实体
@Entity
public class JobLog {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private String id;

    @QueryType(PropertyType.STRING)
    private LocalizedString message;
}

问题

我想对消息执行一些谓词。不幸的是,使用这个配置,我不能这样做:

predicates.and(jobLog.message.likeIgnoreCase(escapedTextFilter));

因为我和所有人都有同样的问题!

解决方案

但我找到了解决方法:)

predicates.and(
(jobLog.id.likeIgnoreCase(escapedTextFilter).and(jobLog.id.isNull()))
    .or(jobLog.message.likeIgnoreCase(escapedTextFilter)));

为什么它解决了这个错误?

  • 重要的是 escapedTextFilter 在两个谓词中是相同的!
  • 事实上,在这种情况下,常量是转换为第一个谓词(String 类型)中的 SQL。在第二个谓词中,我们使用conterted value

坏事?

添加性能溢出,因为我们在谓词中有 OR 希望这可以帮助某人:)

我找到了解决这个问题的方法,我的主要想法是使用 mysql 函数 cast(xx as char) 来欺骗 hibrenate。以下是我的基本信息。我的代码是为了工作,所以我做了一个例子。

// StudentRepo.java
public interface StudentRepo<Student, Long> extends JpaRepository<Student, Long>,  QuerydslPredicateExecutor<Student>, JpaSpecificationExecutor<Student> {
}

// Student.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(of = "id")
@Entity
@Builder
@Table(name = "student")
public class Student {
    @Convert(converter = ClassIdsConvert.class)
    private List<String> classIds;
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}

// ClassIdsConvert.java
public class ClassIdsConvert implements AttributeConverter<List<String>, String> {
    @Override
    public String convertToDatabaseColumn(List<String> ips) {
        // classid23,classid24,classid25
        return String.join(",", ips);
    }
    @Override
    public List<String> convertToEntityAttribute(String dbData) {
        if (StringUtils.isEmpty(dbData)) {
            return null;
        } else {
            return Stream.of(dbData.split(",")).collect(Collectors.toList());
        }
    }
}

我的数据库低于

id classIds name address
1 2,3,4,11 join 北京市
2 2,31,14,11 hell 福建省
3 2,12,22,33 work 福建省
4 1,4,5,6 ouy 广东省
5 11,31,34,22 yup 上海市
-- ----------------------------
-- Table structure for student
-- ----------------------------
DROP TABLE IF EXISTS `student`;
CREATE TABLE `student`  (
  `id` int(11) NOT NULL,
  `classIds` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;
  1. 使用JpaSpecificationExecutor解决问题
Specification<Student> specification = (root, query, criteriaBuilder) -> {
    String classId = "classid24"
    String classIdStr = StringUtils.wrap(classId, "%");
    var predicate = criteriaBuilder.like(root.get("classIds").as(String.class), classIdStr);
    return criteriaBuilder.or(predicate);
};
var students = studentRepo.findAll(specification);
log.info(new Gson().toJson(students))

attention the code root.get("classIds").as(String.class)

在我看来,如果我不添加 .as(String.class) ,hibernate 会认为 student.classIds 的类型是列表并抛出如下异常。

SQL will like below which runs correctly in mysql. But hibnerate can't work.

org.springframework.dao.InvalidDataAccessApiUsageException: Parameter value [%classid24%] did not match expected type [java.util.List (n/a)]; nested exception is java.lang.IllegalArgumentException: Parameter value [%classid24%] did not match expected type [java.util.List (n/a)]
    
SELECT
    student0_.id AS id1_0_,
    student0_.class_ids AS class_ids2_0_
FROM
    student student0_ 
WHERE
    student0_.class_ids LIKE '%classid24%' ESCAPE '!'

如果您添加 .as(String.class) ,hibnerate 会将 student.classIds 的类型视为字符串并且根本不会检查它。

SQL会像下面这样可以运行更正mysql。也在 JPA 中。

SELECT
    student0_.id AS id1_0_,
    student0_.class_ids AS class_ids2_0_
FROM
    student student0_ 
WHERE
    cast( student0_.class_ids AS CHAR ) LIKE '%classid24%' ESCAPE '!'
  1. JpaSpecificationExecutor解决了这个问题,所以我认为这也可以在querydsl中解决。终于在querydsl中找到了template思路。
String classId = "classid24";
StringTemplate st = Expressions.stringTemplate("cast({0} as string)", qStudent.classIds);
var students = Lists.newArrayList<studentRepo.findAll(st.like(StringUtils.wrap(classId, "%"))));
log.info(new Gson().toJson(students));

它的sql如下所示。

SELECT
    student0_.id AS id1_0_,
    student0_.class_ids AS class_ids2_0_
FROM
    student student0_ 
WHERE
    cast( student0_.class_ids AS CHAR ) LIKE '%classid24%' ESCAPE '!'