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

I have a JPA Entity (Terminal) which uses an AttributeConverter to convert a Database String into a list of Objects (ProgrmRegistration). The converter just uses a JSON ObjectMapper to turn the JSON String into POJO objects.

Entity Object

@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;
    }
}

The Terminal uses the following JPA AttributeConverter to serialize the Objects from and to JSON

JPA AttributeConverter

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;
    }

}

I am using Spring Boot and a JPARepository to fetch a Page of Terminal results from the Database. To filter the results I am using a BooleanExpression as the Predicate. For all the filter values on the Entity it works well, but the List of objects converted from the JSON string does not allow me to easily write an Expression that will filter the Objects in the list.

REST API that is trying to filter the Entity Objects using 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);
}

I am wanting to get any Terminals that have a ProgramRegistration object with the program name equal to the parameter passed into the REST service.

I have been trying to get CollectionExpression or SubQueryExpression working without success since they all seem to be wanting to perform a join between two Entity objects. I do not know how to create the path and query so that it can iterate over the programRegistrations checking the "program" field for a match. I do not have a QProgamRegistration object to join with, since it is just a list of POJOs.

How can I get the predicate to match only the Terminals that have programs with the name I am searching for?

This is the line that is not working:

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



Solution 1:[1]

Although the following desired QueryDsl looks like it should work

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

In reality JPA would never be able to convert it into something that would make sense in terms of SQL. The only SQL that JPA could convert it into could be as follows:

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

This would work in this use case, but be semantically wrong, and therefore it is correct that it should not work. Trying to select unstructured data using a structured query language makes no sense

The only solution is to treat the data as characters for the DB search criteria, and to treat it as a list of Objects after the query completes and then perform filtering of the rows in Java. Although This makes the paging feature rather useless.

One possible solution is to have a secondary read only String version of the column that is used for the DB search criteria, that is not converted to JSON by the AttributeConverter.

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

The real solution is do not use unstructured data when you want structured queries on that data Therefore convert the data to either a database that supports the JSON natively for queries or model the data correctly in DDL.

Solution 2:[2]

AttributeConverters have issues in Querydsl, because they have issues in JPQL - the query language of JPA - itself. It is unclear what actually the underlying query type of the attribute is, and whether the parameter should be a basic type of that query type, or should be converted using the conversion. Such conversion, whilst it appears logical, is not defined in the JPA specification. Thus a basic type of the query type needs to be used instead, which leads to new difficulties, because Querydsl can't know the type it needs to be. It only knows the Java type of the attribute.

A workaround can be to force the field to result into a StringPath by annotating the field with @QueryType(PropertyType.STRING). Whilst this fixes the issue for some queries, you will run into different issues in other scenarios. For more information, see this thread.

Solution 3:[3]

To have a short answer: the parameter used in the predicate on attribute with @QueryType must be used in another predicate on attribute of type String.

It's a clearly known issue describe in this thread: https://github.com/querydsl/querydsl/issues/2652


I simply want to share my experience about this bug.

Model

I have an entity like

@Entity
public class JobLog {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private String id;

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

Issue

I want to perform some predicate about message. Unfortunately, with this configuration, I can't do this:

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

because I have the same issues that all people!

Solution

But I find a way to workaround :)

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

Why it workaround the bug?

  • It's important that escapedTextFilter is the same in both predicate!
  • Indeed, in this case, the constant is converter to SQL in the first predicate (which is of String type). And in the second predicate, we use the conterted value

Bad thing?

Add a performance overflow because we have OR in predicate Hope this can help someone :)

Solution 4:[4]

I've found one way to solve this problem, my main idea is to use mysql function cast(xx as char) to cheat hibrenate. Below is my base info. My code is for work , so I've made an example.

// 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());
        }
    }
}

my db is below

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. Use JpaSpecificationExecutor solve the problem
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)

In my opinion, if I don't add .as(String.class) , hibernate will think the type of student.classIds is list and throw an Exception as below.

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 '!'

if you add .as(String.class) , hibnerate will think the type of student.classIds as string and won't check it at all.

SQL will be like below which can run correct in mysql. Also in 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. when the problem is solved by JpaSpecificationExecutor, so I think this can be solve also in querydsl. At last I find the template idea in querydsl.
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));

it's sql is like below.

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 '!'

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 AndreJ
Solution 2 Jan-Willem Gmelig Meyling
Solution 3
Solution 4 Emi OB