DEV Community

Li
Li

Posted on

easy-query vs querydsl

In the Java ecosystem, querydsl is one of the most popular strongly-typed query frameworks, often used with JPA/Hibernate. This article compares these two frameworks across multiple dimensions, exploring the evolution of strongly-typed query DSLs.


Basic Query Comparison

Given a User entity, query users whose name contains "test".

querydsl

QUser user = QUser.user;

List<User> users = queryFactory
    .selectFrom(user)
    .where(user.name.like("%test%"))
    .fetch();
Enter fullscreen mode Exit fullscreen mode

easy-query

List<User> users = entityQuery.queryable(User.class)
    .where(u -> u.name().like("test"))
    .toList();
Enter fullscreen mode Exit fullscreen mode

Differences:

  • querydsl requires pre-defining a QUser variable
  • easy-query uses Lambda, no extra variables needed, and like auto-adds % wildcards

Join Query Comparison

Scenario: Query users where the company name contains "Tech".

querydsl

QUser user = QUser.user;
QCompany company = QCompany.company;

List<User> users = queryFactory
    .selectFrom(user)
    .join(user.company, company)
    .where(company.name.like("%Tech%"))
    .fetch();
Enter fullscreen mode Exit fullscreen mode

easy-query

List<User> users = entityQuery.queryable(User.class)
    .where(u -> u.company().name().like("Tech"))
    .toList();
Enter fullscreen mode Exit fullscreen mode

Differences:

  • querydsl requires explicit join declaration and related Q-classes
  • easy-query uses navigation properties for implicit joinu.company().name() automatically generates the JOIN clause

The generated SQL is identical, but easy-query's code is more concise and closer to natural language.


Subquery Comparison

Scenario: Query users who have comments.

querydsl

QUser user = QUser.user;
QComment comment = QComment.comment;

List<User> users = queryFactory
    .selectFrom(user)
    .where(JPAExpressions
        .selectOne()
        .from(comment)
        .where(comment.userId.eq(user.id))
        .exists())
    .fetch();
Enter fullscreen mode Exit fullscreen mode

easy-query

List<User> users = entityQuery.queryable(User.class)
    .where(u -> u.comments().any())
    .toList();
Enter fullscreen mode Exit fullscreen mode

Differences:

  • querydsl requires JPAExpressions to manually construct subqueries
  • easy-query generates EXISTS subquery implicitly via comments().any()

Aggregate Subquery Comparison

Scenario: Query users with more than 10 comments.

querydsl

QUser user = QUser.user;
QComment comment = QComment.comment;

List<User> users = queryFactory
    .selectFrom(user)
    .where(JPAExpressions
        .select(comment.count())
        .from(comment)
        .where(comment.userId.eq(user.id))
        .gt(10L))
    .fetch();
Enter fullscreen mode Exit fullscreen mode

easy-query

List<User> users = entityQuery.queryable(User.class)
    .where(u -> u.comments().count().gt(10L))
    .toList();
Enter fullscreen mode Exit fullscreen mode

Differences:

  • querydsl requires a complete subquery expression
  • easy-query does it in one line: u.comments().count().gt(10L)

Dynamic Query Comparison

Scenario: Dynamically build query conditions based on frontend parameters.

querydsl

QUser user = QUser.user;
BooleanBuilder builder = new BooleanBuilder();

if (StringUtils.hasText(name)) {
    builder.and(user.name.like("%" + name + "%"));
}
if (minAge != null) {
    builder.and(user.age.goe(minAge));
}
if (companyName != null) {
    QCompany company = QCompany.company;
    // Need to add join externally
    builder.and(company.name.eq(companyName));
}

List<User> users = queryFactory
    .selectFrom(user)
    .join(user.company, company)  // Must explicitly join
    .where(builder)
    .fetch();
Enter fullscreen mode Exit fullscreen mode

easy-query

List<User> users = entityQuery.queryable(User.class)
    .where(u -> {
        u.name().like(name);                           // Auto-skips if empty
        u.age().ge(minAge);                            // Auto-skips if null
        u.company().name().eq(companyName);            // No join if condition inactive
    })
    .toList();
Enter fullscreen mode Exit fullscreen mode

Differences:

  • querydsl requires BooleanBuilder for manual concatenation, and joins must be declared explicitly (even if conditions don't apply)
  • easy-query auto-skips null/empty conditions, implicit join doesn't generate JOIN when condition is inactive

Complex Aggregation Comparison

Scenario: Count users and average age per department.

querydsl

QUser user = QUser.user;

List<Tuple> result = queryFactory
    .select(user.department, user.count(), user.age.avg())
    .from(user)
    .groupBy(user.department)
    .fetch();

// Manual Tuple handling required
for (Tuple tuple : result) {
    String dept = tuple.get(user.department);
    Long count = tuple.get(user.count());
    Double avgAge = tuple.get(user.age.avg());
}
Enter fullscreen mode Exit fullscreen mode

easy-query

List<Draft3<String, Long, BigDecimal>> result = entityQuery.queryable(User.class)
    .groupBy(u -> GroupBy.of(u.department()))
    .select(u -> Select.DRAFT.of(
        u.key1(),
        u.count(),
        u.age().avg()
    ))
    .toList();

// Strongly-typed access
for (var row : result) {
    String dept = row.getValue1();
    Long count = row.getValue2();
    BigDecimal avgAge = row.getValue3();
}
Enter fullscreen mode Exit fullscreen mode

Differences:

  • querydsl returns Tuple, requiring manual extraction via expressions
  • easy-query returns strongly-typed Draft with compile-time type checking

Conditional Aggregation Comparison

Scenario: Count users in Department A and Department B separately.

querydsl

QUser user = QUser.user;

List<Tuple> result = queryFactory
    .select(
        new CaseBuilder()
            .when(user.department.eq("A")).then(1L).otherwise(0L).sum(),
        new CaseBuilder()
            .when(user.department.eq("B")).then(1L).otherwise(0L).sum()
    )
    .from(user)
    .fetch();
Enter fullscreen mode Exit fullscreen mode

easy-query

List<Draft2<Long, Long>> result = entityQuery.queryable(User.class)
    .select(u -> Select.DRAFT.of(
        u.id().count().filter(() -> u.department().eq("A")),
        u.id().count().filter(() -> u.department().eq("B"))
    ))
    .toList();
Enter fullscreen mode Exit fullscreen mode

Differences:

  • querydsl requires manually building CASE WHEN expressions
  • easy-query uses .filter() syntax sugar for clarity

Related Data Loading Comparison

Scenario: Query users and load their roles.

querydsl + JPA

// Option 1: EntityGraph
@EntityGraph(attributePaths = {"roles"})
List<User> findAll();

// Option 2: Fetch Join
List<User> users = queryFactory
    .selectFrom(user)
    .leftJoin(user.roles).fetchJoin()
    .fetch();
// Problem: Multiple collection fetch joins cause Cartesian products
Enter fullscreen mode Exit fullscreen mode

easy-query

List<User> users = entityQuery.queryable(User.class)
    .include(u -> u.roles())
    .toList();

// Supports multiple includes without Cartesian products
List<User> users = entityQuery.queryable(User.class)
    .include(u -> u.roles())
    .include(u -> u.posts())
    .toList();

// Supports deep nesting
List<User> users = entityQuery.queryable(User.class)
    .include2((c, u) -> {
        c.query(u.roles());
        c.query(u.posts().flatElement().comments());
    })
    .toList();
Enter fullscreen mode Exit fullscreen mode

Differences:

  • JPA's fetch join with multiple collections causes Cartesian product issues
  • easy-query's include uses separate queries, avoiding Cartesian products

DTO Projection Comparison

Scenario: Query users and return a DTO that includes associated roles.

querydsl + JPA

// 1. Define DTO (no nested associations supported)
public record UserDTO(String id, String name) {}

List<UserDTO> users = queryFactory
    .select(Projections.constructor(UserDTO.class, user.id, user.name))
    .from(user)
    .fetch();

// To get related data, need separate queries and manual assembly
Enter fullscreen mode Exit fullscreen mode

easy-query

@Data
public class UserDTO {
    private String id;
    private String name;

    @Navigate(value = RelationTypeEnum.ManyToMany)
    private List<RoleDTO> roles;  // Supports nested associations
}

List<UserDTO> users = entityQuery.queryable(User.class)
    .selectAutoInclude(UserDTO.class)
    .toList();
// Roles auto-populated
Enter fullscreen mode Exit fullscreen mode

Differences:

  • querydsl projections don't support nested associations
  • easy-query's selectAutoInclude auto-populates navigation properties in DTOs

Feature Comparison Table

Feature querydsl easy-query
Strongly-typed queries
Code generation APT (Q-class) APT (Proxy)
Lambda syntax
Implicit Join
Implicit Subquery
Dynamic condition skip Manual Automatic
Conditional aggregation sugar .filter()
Multi-collection Include Cartesian product ✅ Separate queries
DTO nested associations selectAutoInclude
Subquery optimization ✅ GROUP JOIN
JPA dependency ❌ Standalone

Summary

querydsl pioneered strongly-typed queries in Java, solving the pain of string-based SQL concatenation. But it remains "explicit" — you must explicitly declare Q-classes, explicitly join, and explicitly construct subqueries.

easy-query takes it further with navigation properties and implicit inference, making code closer to business semantics:

  • u.company().name().like("Tech") instead of join(user.company, company).where(company.name.like(...))
  • u.comments().any() instead of JPAExpressions.selectOne().from(comment).where(...).exists()
  • u.comments().count().gt(10) instead of JPAExpressions.select(comment.count()).from(comment).where(...).gt(10)

You write business logic; the framework generates optimal SQL.


Links

Top comments (0)