How Can We Tolerate Magic Values! Major Overhaul of JPA Specification!

发表于 2024-08-01 23:00 2141 字 11 min read

Writing a Simple Query API If you were to construct a programmatic SQL using JPA, how would you do it? Generally, we use Specification to build conditions for dynamic conditional queries. For example,...

Writing a Simple Query API

If you were to construct a programmatic SQL using JPA, how would you do it? Generally, we use Specification to build conditions for dynamic conditional queries. For example, in my project, it is often written like this:

/**
     * <p>Query order information based on conditions</p>
     * @param orderQueryCondition Order query condition
     * @return java.util.List<com.demo.po.OrderPO>
     */
    public List<OrderPO> findByCondition(OrderQueryCondition orderQueryCondition) {

        Specification specification = (root, criteriaQuery, criteriaBuilder) -> {
            List<Predicate> predicates = new ArrayList<>();

            if(!StringUtils.isEmpty(orderQueryCondition.getOrderItemName())) {
                predicates.add(criteriaBuilder.equal(root.get("orderItemName"), orderQueryCondition.getOrderItemName()));
            }

            if(!StringUtils.isEmpty(orderQueryCondition.getOrderNo())) {
                predicates.add(criteriaBuilder.equal(root.get("orderNo"), orderQueryCondition.getOrderNo()));
            }

            return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
        };

        return orderDao.findAll(specification);

    }

The Code is Simple, Let’s Explain Briefly

It essentially implements the toPredicate() method of the Specification interface using a lambda expression. Inside the orderDao.findAll(specification) method, its implementation class SimpleJpaRepository will obtain the EntityManager object from the container, respectively retrieving the three objects needed by the toPredicate() method: root, criteriaQuery, and criteriaBuilder. It ultimately constructs the Predicate object, which is the final condition encapsulation object required by JPA.

Is There a Problem with This Code?

From an execution standpoint, it’s quite good. It can dynamically generate SQL, making it very flexible (for now, please ignore issues like indexing in this example). The code is written very clearly and is easy to understand.

But is it Good?

I don’t think so. Because it has magic values.

Magic Values

What are magic values? They’re not the magic points in games. They refer to hardcoded values or strings. Magic values have two major drawbacks:

Not Easy to Understand

The place where I see the most magic values in projects is in some if condition judgments, like the following code:

if ("1".equals(this.medicareFlag) || "2".equals(this.medicareFlag)){
    return 1;
}else if ("0".equals(this.medicareFlag)){
    return 0;
}else {
    return 2;
}

If you are the one maintaining this code, wouldn’t you have a headache seeing this? What is 1, what is 2, who are you, and who am I?

Not Easy to Maintain

For example, if you have to judge an order status and you don’t declare this status as a variable, but instead judge it everywhere like the code above, then if you need to change this status name, you’ll have to modify it everywhere. For example:

public void method1(Order order) {
    if("Paid".equals(order)) {
        // blabla...
    }
}
public void method2(Order order) {
    if("Paid".equals(order)) {
        // blabla...
    }
}

If written like the above, and one day the customer suddenly says they want to change “Paid” to “Payment Completed,” you’ll be stuck modifying everywhere.

Remove Magic Values!

From the examples above, you should know the drawbacks of magic values. Similarly, the initial query example also uses magic values. Although field names rarely change, this kind of writing is prone to errors. Furthermore, it’s better to be safe than sorry; if you really need to change a field name, it’ll be a nightmare.

Actually, mybatis-plus has a similar feature. It defines LambdaQueryWrapper and LambdaQueryChainWrapper to construct query conditions using lambdas. LambdaQueryChainWrapper even allows chain construction.

Since we’re going for it, I decided to imitate LambdaQueryChainWrapper to create a tool that allows lambda for field names while also enabling chain construction.

Get Hands-On!

Review the Principle Before Doing It

Remember the principle of constructing conditions with Specification that we explained earlier? Let’s take a look at the source code of SimpleJpaRepository.

Expand to view: Part of the SimpleJpaRepository source code
private final EntityManager em;

/*
 * (non-Javadoc)
 * @see org.springframework.data.jpa.repository.JpaSpecificationExecutor#findAll(org.springframework.data.jpa.domain.Specification)
 */
@Override
public List<T> findAll(@Nullable Specification<T> spec) {
    // This method is the entry point called through the Dao
    return getQuery(spec, Sort.unsorted()).getResultList();
}

/**
 * Creates a {@link TypedQuery} for the given {@link Specification} and {@link Sort}.
 *
 * @param spec can be {@literal null}.
 * @param sort must not be {@literal null}.
 * @return
 */
protected TypedQuery<T> getQuery(@Nullable Specification<T> spec, Sort sort) {
    return getQuery(spec, getDomainClass(), sort);
}

/**
 * Creates a {@link TypedQuery} for the given {@link Specification} and {@link Sort}.
 *
 * @param spec can be {@literal null}.
 * @param domainClass must not be {@literal null}.
 * @param sort must not be {@literal null}.
 * @return
 */
protected <S extends T> TypedQuery<S> getQuery(@Nullable Specification<S> spec, Class<S> domainClass, Sort sort) {

    // Get CriteriaBuilder and CriteriaQuery objects through the EntityManager obtained from the container
    CriteriaBuilder builder = em.getCriteriaBuilder();
    CriteriaQuery<S> query = builder.createQuery(domainClass);

    Root<S> root = applySpecificationToCriteria(spec, domainClass, query);
    query.select(root);

    if (sort.isSorted()) {
        query.orderBy(toOrders(sort, root, builder));
    }

    return applyRepositoryMethodMetadata(em.createQuery(query));
}

/**
 * Applies the given {@link Specification} to the given {@link CriteriaQuery}.
 *
 * @param spec can be {@literal null}.
 * @param domainClass must not be {@literal null}.
 * @param query must not be {@literal null}.
 * @return
 */
private <S, U extends T> Root<U> applySpecificationToCriteria(@Nullable Specification<U> spec, Class<U> domainClass,
        CriteriaQuery<S> query) {

    Assert.notNull(domainClass, "Domain class must not be null!");
    Assert.notNull(query, "CriteriaQuery must not be null!");

    Root<U> root = query.from(domainClass);

    if (spec == null) {
        return root;
    }

    // The core part, obtaining CriteriaBuilder from the EntityManager, and calling the toPredicate() method of Specification
    CriteriaBuilder builder = em.getCriteriaBuilder();
    Predicate predicate = spec.toPredicate(root, query, builder);

    if (predicate != null) {
        query.where(predicate);
    }

    return root;
}

As you can see, the toPredicate() method of the Specification object is called in the applySpecificationToCriteria() method in the source code, which is the core part. So, we just need to implement the toPredicate() method, and we can complete our tool.

Implement the Specification Interface and Define a LambdaSpecification Class

First, we implement the Specification interface to analyze what else we need to prepare.

From the analysis of the native writing method, it uses List to put all conditions into a collection and finally constructs a Predicate object through criteriaBuilder.

So, should we also define a List?

No, because from the principle above, we know that Specification is constructed through lambda, and the actual root, criteriaQuery, and criteriaBuilder objects are only passed in when the SimpleJpaRepository object is executed. This means we cannot get these objects when writing our chain methods.

What should we do then?

It’s simple; just create a Function like it. We need to receive the Root and CriteriaBuilder class objects, so we define a BiFunction to receive them.

private List<BiFunction<Root, CriteriaBuilder, Predicate>> predicateFunctions;

Then, when defining chain condition methods, we write like this. Our equal condition is named eq(). With null-check handling:

public LambdaSpecification<T> eq(Boolean ignoreNull, String columnName, Object value) {
    // Choose whether to ignore null values, i.e., not adding this condition if null
    if(ignoreNull && value == null) {
        return this;
    }
    // Put the function into the List, to be processed by toPredicate() later
    predicateFunctions.add((root, criteriaBuilder) -> criteriaBuilder.equal(root.get(columnName), value));
    return this;
}

Thus, our LambdaSpecification class is completed:

public class LambdaSpecification<T> implements Specification<T> {

    private List<BiFunction<Root, CriteriaBuilder, Predicate>> predicateFunctions;

    private LambdaSpecification() {
        this.predicateFunctions = new ArrayList<>();
    }

    public static <T> LambdaSpecification<T> query() {
        return new LambdaSpecification<>();
    }

    public LambdaSpecification<T> eq(Boolean ignoreNull, String columnName, Object value) {
        // Choose whether to ignore null values, i.e., not adding this condition if null
        if(ignoreNull && value == null) {
            return this;
        }
        // Put the function into the List, to be processed by toPredicate() later
        predicateFunctions.add((root, criteriaBuilder) -> criteriaBuilder.equal(root.get(columnName), value));
        return this;
    }

    @Override
    public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
        // Execute the predicateFunctions constructed earlier to get Predicate objects
        List<Predicate> predicates = predicateFunctions.stream()
                    .map(function -> function.apply(root, criteriaBuilder))
                    .collect(Collectors.toList());
        // Construct the final Predicate object
        return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
    }

}

Then, refactor our calling code:

Specification specification = LambdaSpecification.query()
        .eq(true, "orderNo", orderQueryCondition.getOrderNo())
        .eq(true, "orderItemName", orderQueryCondition.getOrderItemName());
return orderDao.findAll(specification);

Writing this way is both refreshing and simple, isn’t it? Perfect, let’s execute it and see the result. I tried two examples, one passing only order_no, and one passing both. It perfectly achieved our functionality. Here’s the executed SQL:

-- SQL with both parameters passed
Hibernate: select orderpo0_.pid as pid1_0_, orderpo0_.order_item_name as order_item_name2_0_, orderpo0_.order_no as order_no3_0_ from ipn_order orderpo0_ where ( orderpo0_.is_available = 'Y') and orderpo0_.order_no=250037 and orderpo0_.order_item_name=?

-- SQL with only order_no passed
Hibernate: select orderpo0_.pid as pid1_0_, orderpo0_.order_item_name as order_item_name2_0_, orde

Adding Lambda Support

The chain call has been perfectly implemented, and now comes the main event: implementing lambda support. Our goal is to be able to pass in something like OrderPO::getOrderNo and recognize the orderNo field name. It’s actually quite simple; we just need to change the String columnName parameter in the eq() method to a Function.

First, if we use Function, we need to see how we can get the method name from the Function.

Actually, the Function object itself does not have a method to get the method name. To get the method name, we need to make it support serialization and convert it to a SerializedLambda object. This is because when a lambda expression supports serialization, Java will implement a writeReplace() method for it, and the object returned by this method is the SerializedLambda object, which contains the method name we want.

Therefore, we cannot directly use the Function class; we need to define a SerializableFunction class that inherits the Function class and supports serialization.

/**
 * <p>A Function that can be serialized</p>
 */
public interface SerializableFunction<T, R> extends Function<T, R>, Serializable {
}

Then, we can happily get the specific method name and field name through reflection:

/**
 * <p>Get field name</p>
 * @param columnNameGetter
 * @return java.lang.String
 */
private <T> String getColumnName(SerializableFunction<T, Object> columnNameGetter) {
    String methodName = getMethodName(columnNameGetter);
    if (methodName.startsWith("get")) {
        String fieldName = methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
        return fieldName;
    } else {
        throw new RuntimeException(String.format("Dynamic query generation failed, the method name must be a get method, the current method name is: %s", methodName));
    }
}

/**
 * <p>Get method name</p>
 * Get the `SerializedLambda` object through the `writeReplace()` method to get the method name
 * @param columnNameGetter
 * @return java.lang.String
 */
@SneakyThrows
private <T> String getMethodName(SerializableFunction<T, Object> columnNameGetter) {
    Method writeReplace = columnNameGetter.getClass().getDeclaredMethod("writeReplace");
    writeReplace.setAccessible(true);
    Object sl = writeReplace.invoke(columnNameGetter);
    SerializedLambda serializedLambda = (SerializedLambda) sl;
    return serializedLambda.getImplMethodName();
}

Additionally, due to generic erasure, if we directly pass in OrderPO::getOrderNo, it will report an error because it cannot recognize the type. Therefore, we need to let our LambdaSpecification object know what type T is. We add a Class object to specify the type.

// Add Class<T> object
private Class<T> poClass;

// Modify the constructor
private LambdaSpecification(Class<T> poClass) {
    this.predicateFunctions = new ArrayList<>();
    this.poClass = poClass;
}

The final modified code is as follows:

Expand to view: Final completed LambdaSpecification code
public class LambdaSpecification<T> implements Specification<T> {

    private List<BiFunction<Root, CriteriaBuilder, Predicate>> predicateFunctions;

    private Class<T> poClass;

    private LambdaSpecification(Class<T> poClass) {
        this.predicateFunctions = new ArrayList<>();
        this.poClass = poClass;
    }

    public static <T> LambdaSpecification<T> query(Class<T> poClazz) {
        return new LambdaSpecification<>(poClazz);
    }

    public LambdaSpecification<T> eq(Boolean ignoreNull, SerializableFunction<T, Object> columnNameGetter, Object value) {
        // Choose whether to ignore null values, i.e., not adding this condition if null
        if(ignoreNull && value == null) {
            return this;
        }
        predicateFunctions.add((root, criteriaBuilder) -> criteriaBuilder.equal(root.get(getColumnName(columnNameGetter)), value));
        return this;
    }

    @Override
    public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
        // Execute the predicateFunctions constructed earlier to get Predicate objects
        List<Predicate> predicates = predicateFunctions.stream()
                    .map(function -> function.apply(root, criteriaBuilder))
                    .collect(Collectors.toList());
        // Construct the final Predicate object
        return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
    }

    /**
     * <p>Get field name</p>
     * @param columnNameGetter
     * @return java.lang.String
     */
    private <T> String getColumnName(SerializableFunction<T, Object> columnNameGetter) {
        String methodName = getMethodName(columnNameGetter);
        if (methodName.startsWith("get")) {
            String fieldName = methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
            return fieldName;
        } else {
            throw new RuntimeException(String.format("Dynamic query generation failed, the method name must be a get method, the current method name is: %s", methodName));
        }
    }

    /**
     * <p>Get method name</p>
     * Get the `SerializedLambda` object through the `writeReplace()` method to get the method name
     * @param columnNameGetter
     * @return java.lang.String
     */
    @SneakyThrows
    private <T> String getMethodName(SerializableFunction<T, Object> columnNameGetter) {
        Method writeReplace = columnNameGetter.getClass().getDeclaredMethod("writeReplace");
        writeReplace.setAccessible(true);
        Object sl = writeReplace.invoke(columnNameGetter);
        SerializedLambda serializedLambda = (SerializedLambda) sl;
        return serializedLambda.getImplMethodName();
    }

}

Let’s modify our calling code:

public List<OrderPO> findByCondition(OrderQueryCondition orderQueryCondition) {
    Specification specification = LambdaSpecification.query(OrderPO.class)
            .eq(true, OrderPO::getOrderNo, orderQueryCondition.getOrderNo())
            .eq(true, OrderPO::getOrderItemName, orderQueryCondition.getOrderItemName());
    return orderDao.findAll(specification);
}

With this, we say goodbye to magic values! The execution result is also correct, hooray!

Hibernate: select orderpo0_.pid as pid1_0_, orderpo0_.order_item_name as order_item_name2_0_, orderpo0_.order_no as order_no3_0_ from ipn_order orderpo0_ where (orderpo0_.is_available = 'Y') and orderpo0_.order_no=250037

The above only demonstrates the equal condition. Conditions like in, like, etc., can be extended as needed. For example, for the in condition:

public LambdaSpecification<T> in(Boolean ignoreNull, SerializableFunction<T, Object> columnNameGetter, Object... values) {
    if (ignoreNull && values == null) {
        return this;
    }
    predicateFunctions.add(
            (root, criteriaBuilder) -> {
                CriteriaBuilder.In in = criteriaBuilder.in(root.get(getColumnName(columnNameGetter)));
                for (Object value : values) {
                    in.value(value);
                }
                return in;
            }
    );
    return this;
}

I won’t elaborate on the others; you can extend them as needed.

That concludes the transformation process. I hope this helps everyone, thank you!

If you enjoyed this, leave a comment~

© 2019 - 2026 VincentHo @VincentHo
Powered by theme astro-koharu · Inspired by Shoka