Skip to content

[Feature Request] Auto-Generate Specifications from Annotated POJOs #3897

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
SWQXDBA opened this issue May 21, 2025 · 5 comments
Closed

[Feature Request] Auto-Generate Specifications from Annotated POJOs #3897

SWQXDBA opened this issue May 21, 2025 · 5 comments

Comments

@SWQXDBA
Copy link
Contributor

SWQXDBA commented May 21, 2025

Feature Request

A very common use case is querying based on forms submitted from the frontend. Users fill out one or more query conditions on the interface. For example, we define a query object for searching Books:

class BookQuery {
    private String bookName;
    private String authorName;
    
    // Getters and setters
}

Then we build the query based on this query object:

Specification<Book> byQuery(BookQuery queryParam) {
    return (root, query, cb) -> {
        List<Predicate> predicates = new ArrayList<>();
        
        if (StringUtils.isNotBlank(queryParam.getBookName())) {
            predicates.add(cb.like(
                root.get("bookName"), 
                "%" + queryParam.getBookName() + "%"
            ));
        }
        
        if (StringUtils.isNotBlank(queryParam.getAuthorName())) {
            predicates.add(cb.like(
                root.get("author").get("name"), 
                "%" + queryParam.getAuthorName() + "%"
            ));
        }
        
        return cb.and(predicates.toArray(new Predicate[0]));
    };
}

Or by mybatis xml

<!-- BookMapper.xml -->
<mapper namespace="com.example.mapper.BookMapper">
    
    <sql id="bookQueryConditions">
        <where>
            <if test="bookName != null and bookName != ''">
                AND book_name LIKE CONCAT('%', #{bookName}, '%')
            </if>
            <if test="authorName != null and authorName != ''">
                AND EXISTS (
                    SELECT 1 FROM author 
                    WHERE author.id = book.author_id
                    AND author.name LIKE CONCAT('%', #{authorName}, '%')
                )
            </if>
        </where>
    </sql>

    <select id="findByQuery" resultType="com.example.model.Book">
        SELECT * FROM book
        <include refid="bookQueryConditions"/>
        ORDER BY id
    </select>

    <select id="countByQuery" resultType="long">
        SELECT COUNT(*) FROM book
        <include refid="bookQueryConditions"/>
    </select>
</mapper>

In my daily work, a significant amount of time is wasted on this meaningless translation process - simply converting query POJOs into ORM-recognizable expressions.

When we used only JPA, this manifested as Predicates in CriteriaQuery. With Spring Data JPA, it's now wrapped as Specifications. This repetitive boilerplate code adds no business value but consumes considerable development time.

This pattern violates the DRY (Don't Repeat Yourself) principle and significantly impacts development efficiency. We need a standardized approach to eliminate this repetitive translation layer.

I propose a solution to automate this translation process, which I call Annotation-based Dynamic Query. This approach uses annotations to mark query objects, then automatically converts POJOs into ORM-recognizable query conditions through a processor.Core Concept:

By annotating fields in query objects, we can dynamically generate the corresponding query predicates (JPA Specification, MyBatis-plus QueryWrapper, etc.) without manual translation.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface QueryCondition {
    String field() default "";       // Target entity field (default: same as query field)
    Operator operator() default Operator.EQ;  // =, LIKE, >, <, etc.
    boolean ignoreNull() default true; // Skip if value is null
}

public enum Operator {
    EQ, LIKE, GT, LT, IN, BETWEEN...
}
public class BookQuery {
    @QueryCondition(operator = Operator.LIKE)
    private String bookName;

    @QueryCondition(field = "author.name", operator = Operator.LIKE)
    private String authorName;
    
    @QueryCondition(operator = Operator.GT, field = "publishDate")
    private LocalDate minPublishDate;
}

We can implement a utility that transforms BookQuery into Specification through annotation reflection. This solution effectively eliminates repetitive and tedious coding while seamlessly integrating with frontend-backend separation architectures.

The workflow becomes straightforward:

Deserialize frontend parameters (e.g., JSON payload) into BookQuery POJO

Automatically convert to Specification

Execute various operations (pagination/list/count queries) with the generated specification

Key Advantages:
✅ Proven Solution - This isn't theoretical. We've successfully implemented this pattern across multiple projects, where it handles over 80% of query cases
✅ Replaces Complex APIs - Eliminates the need for cumbersome Criteria API constructions and error-prone HQL
✅ Full Coverage - Our real-world validation confirms it satisfies most application scenarios
✅ Seamless Integration - Naturally fits modern RESTful APIs with JSON payloads

While this has proven valuable in our projects, I'm uncertain about broader interest. Should the Spring Data maintainers find this enhancement worthwhile, I'd be glad to discuss.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label May 21, 2025
@mp911de
Copy link
Member

mp911de commented May 26, 2025

Have you tried using Query by Example?

@SWQXDBA
Copy link
Contributor Author

SWQXDBA commented May 26, 2025

Have you tried using Query by Example?

Sure!

While QueryByExample offers some convenient features, we've found its practical application to be somewhat limited in real-world scenarios. Our experience suggests it may not be the most versatile solution, as it comes with certain functional constraints that can impact implementation flexibility.

It requires using an entity as the query parameter, which is unacceptable. The query parameter object and the domain object should clearly be two separate models. In a web scenario, the query object is actually deserialized from a frontend JSON. Therefore, if QueryByExample is to be used, manual translation of the query object into an Example Entity is required. This does not reduce the workload—it merely shifts from writing Criteria to writing an Example Entity and Matcher.

For example, if you need to query the creator of data by either name or employee ID (jobNumber):

Different properties use AND conditions, while multiple annotations on the same property represent OR conditions. This is the most typical use case—allowing a single attribute to match against multiple columns in the database.

@Entity
class Order {
    // other properties  

    @ManyToOne  
    User createBy;  

    // A snapshot of the creator's name when the order was created  
    // (since the user might change their nickname later)  
    String creatorNameSnapshot;  


    int createYear;
}  

class OrderQuery {  

    @QueryCondition(value = "createBy.username", operator = Operator.LIKE)  
    @QueryCondition(value = "createBy.jobNumber", operator = Operator.LIKE)  
    @QueryCondition(value = "creatorNameSnapshot", operator = Operator.LIKE)  
    String creator;  

   @QueryCondition(value = "createYear", operator = Operator.GE)  
    int createYearAfterAndEqual;

 

}  

Query request ==>
/order?creator=jack&createYearAfterAndEqual=2020

Auto generated Predicates like==>

WHERE 
    (
        (o.createById IN (SELECT u1.id FROM user u1 WHERE u1.username LIKE ?)) 
        OR 
        (o.createById IN (SELECT u2.id FROM user u2 WHERE u2.jobNumber LIKE ?))
        OR 
        (o.creatorNameSnapshot LIKE ?)
    ) 
    AND 
    (o.createYear >= 2020)

Criteria api

Using the Criteria API to build this dynamic query would be notoriously cumbersome, and Query by Example likely couldn't even handle such a complex query scenario.

(codes generate by AI but it looks work)

@Repository
public class OrderRepositoryImpl {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    public List<Order> findOrdersByCriteria(String creator, Integer createYearAfterAndEqual) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> query = cb.createQuery(Order.class);
        Root<Order> orderRoot = query.from(Order.class);
        
        List<Predicate> predicates = new ArrayList<>();
        
        // Handle creator condition - OR logic for multiple fields
        if (creator != null && !creator.trim().isEmpty()) {
            String likePattern = "%" + creator + "%";
            
            // Subquery for createBy.username
            Subquery<Long> usernameSubquery = query.subquery(Long.class);
            Root<User> userRoot1 = usernameSubquery.from(User.class);
            usernameSubquery.select(userRoot1.get("id"))
                           .where(cb.like(userRoot1.get("username"), likePattern));
            
            // Subquery for createBy.jobNumber
            Subquery<Long> jobNumberSubquery = query.subquery(Long.class);
            Root<User> userRoot2 = jobNumberSubquery.from(User.class);
            jobNumberSubquery.select(userRoot2.get("id"))
                            .where(cb.like(userRoot2.get("jobNumber"), likePattern));
            
            // Combine all creator-related conditions with OR
            Predicate creatorPredicate = cb.or(
                cb.in(orderRoot.get("createBy").get("id")).value(usernameSubquery),
                cb.in(orderRoot.get("createBy").get("id")).value(jobNumberSubquery),
                cb.like(orderRoot.get("creatorNameSnapshot"), likePattern)
            );
            
            predicates.add(creatorPredicate);
        }
        
        // Handle createYearAfterAndEqual condition
        if (createYearAfterAndEqual != null) {
            predicates.add(cb.greaterThanOrEqualTo(
                orderRoot.get("createYear"), createYearAfterAndEqual));
        }
        
        // Apply all predicates with AND logic
        if (!predicates.isEmpty()) {
            query.where(cb.and(predicates.toArray(new Predicate[0])));
        }
        
        return entityManager.createQuery(query).getResultList();
    }
}

Another advantage is that such query objects are highly intuitive. Any project maintainer can easily understand how the conditions will be applied when this query object is passed in, without needing to examine underlying implementation details like Matchers, nor study the construction process of Criteria or HQL. Simply by declaring how each query parameter's conditions should be used through annotations, the system can operate automatically.

This mechanism aligns perfectly with modern frontend-backend separation development patterns. Frontends pass conditions via form submissions, JSON payloads, or URL query parameters. These conditions are then converted into a query object (POJO) in the controller. By parsing the field values and annotations on this query object, we construct the query and automatically generate a Specification.

With this capability, when developing form queries with dynamic conditions, we only need to declare the query parameters without writing underlying SQL/HQL/Criteria/QueryDSL code. While this approach may not cover all possible scenarios, it sufficiently meets the majority of requirements.

@mp911de
Copy link
Member

mp911de commented May 26, 2025

Alright, thank you. We have some related functionality in Spring Data REST allowing to customize bindings for Querydsl Predicate based on query-string. It it similar to what you're trying to achieve but requires Querydsl usage.

With your rather specific requirements, I suggest to prototype some functionality outside of Spring Data to see how it goes regarding challenges and assumptions.

When trying to match two different classes that are structurally similar, there's no tight contract that would couple both types and the only way to find out that something was renamed or has changed is by running tests. That isn't ideal for Spring Data, where we want to eliminate such class of potential bugs.

@mp911de mp911de added the status: waiting-for-feedback We need additional information before we can continue label May 26, 2025
@spring-projects-issues
Copy link

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

@spring-projects-issues spring-projects-issues added the status: feedback-reminder We've sent a reminder that we need additional information before we can continue label Jun 2, 2025
@spring-projects-issues
Copy link

Closing due to lack of requested feedback. If you would like us to look at this issue, please provide the requested information and we will re-open the issue.

@spring-projects-issues spring-projects-issues closed this as not planned Won't fix, can't repro, duplicate, stale Jun 9, 2025
@spring-projects-issues spring-projects-issues removed status: waiting-for-feedback We need additional information before we can continue status: feedback-reminder We've sent a reminder that we need additional information before we can continue status: waiting-for-triage An issue we've not yet triaged labels Jun 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants