DEV Community

Li
Li

Posted on

easy-query ORM: EF Core for Java

If you've ever used Entity Framework Core in .NET and then switched back to Java, you know the pain. JPA gives you JPQL strings. MyBatis gives you XML files. QueryDSL gets closer but still feels like ceremony. None of them let you write queries the way EF Core does — with real type safety, navigation properties that just work, and DTO projections that don't make you want to flip a table.

easy-query is a Java ORM that finally closes that gap. It's not a port of EF Core, but it clearly takes inspiration from the same philosophy: queries should read like code, not strings.

First Impressions

Here's what a query looks like. No XML, no JPQL, no string concatenation:

List<SysUser> users = easyEntityQuery.queryable(SysUser.class)
    .where(user -> {
        user.name().like("John");
        user.age().gt(18);
    })
    .orderBy(user -> user.createTime().desc())
    .toList();
Enter fullscreen mode Exit fullscreen mode

That lambda gives you full IDE autocompletion. name() returns a string column type, age() returns a number column type — you can't accidentally call .like() on an integer. The SQL it generates is exactly what you'd expect:

SELECT t.id, t.name, t.age, t.create_time 
FROM t_user t 
WHERE t.name LIKE '%John%' AND t.age > 18 
ORDER BY t.create_time DESC
Enter fullscreen mode Exit fullscreen mode

Entity Definition

Entities look similar to JPA, with a twist — you annotate them with @EntityProxy, and the framework generates a type-safe proxy class at compile time via APT:

@Table("t_user")
@EntityProxy
@Data
public class SysUser implements ProxyEntityAvailable<SysUser, SysUserProxy> {
    @Column(primaryKey = true)
    private String id;
    private String name;
    private Integer age;
    private LocalDateTime createTime;

    @Navigate(value = RelationTypeEnum.ManyToOne, selfProperty = "companyId")
    private Company company;

    @Navigate(value = RelationTypeEnum.OneToMany, targetProperty = "userId")
    private List<BankCard> bankCards;

    @Navigate(value = RelationTypeEnum.ManyToMany,
            mappingClass = UserRole.class,
            selfMappingProperty = "userId",
            targetMappingProperty = "roleId")
    private List<SysRole> roles;
}
Enter fullscreen mode Exit fullscreen mode

The generated SysUserProxy is what powers the lambda API. You never write it by hand — the annotation processor handles it. This is the same idea as QueryDSL's Q-classes, but the query API on top of it is far more expressive.

Navigation Properties (The Good Part)

This is where things get interesting. If you define a @Navigate relationship, you can filter through it directly:

List<SysUser> users = easyEntityQuery.queryable(SysUser.class)
    .where(user -> user.company().name().like("Acme"))
    .orderBy(user -> user.company().registerMoney().desc())
    .toList();
Enter fullscreen mode Exit fullscreen mode

The framework figures out the JOIN for you. No .join() call, no ON clause, no alias juggling. It reads the @Navigate metadata, sees it's a ManyToOne, and generates:

SELECT t.* FROM t_user t 
INNER JOIN t_company t1 ON t.company_id = t1.id 
WHERE t1.name LIKE '%Acme%' 
ORDER BY t1.register_money DESC
Enter fullscreen mode Exit fullscreen mode

easy-query calls these "implicit joins" — and it goes further. For OneToMany and ManyToMany navigations, it generates subqueries instead of JOINs, which avoids the cartesian explosion problem that plagues naive JOIN-based ORMs.

Dynamic Filtering (No More If-Else Chains)

Every Java developer has written this:

var query = repository.createQuery();
if (name != null) query.addCondition("name LIKE ?", name);
if (age != null) query.addCondition("age > ?", age);
if (status != null) query.addCondition("status = ?", status);
Enter fullscreen mode Exit fullscreen mode

easy-query bakes this into the DSL with a boolean condition parameter:

List<SysUser> users = easyEntityQuery.queryable(SysUser.class)
    .where(user -> {
        user.name().like(StringUtils.isNotBlank(name), name);
        user.age().gt(age != null, age);
        user.status().eq(status != null, status);
    })
    .toList();
Enter fullscreen mode Exit fullscreen mode

When the condition is false, that predicate is simply skipped. No if-else, no null checks scattered around, no query builder pattern. It composes cleanly.

There's an even more aggressive option — filterConfigure that auto-skips null/empty values globally:

List<SysUser> users = easyEntityQuery.queryable(SysUser.class)
    .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT)
    .where(user -> {
        user.name().like(name);   // skipped if name is null or ""
        user.age().gt(age);       // skipped if age is null
    })
    .toList();
Enter fullscreen mode Exit fullscreen mode

DTO Projection: selectAutoInclude

This is the feature that made me write this post.

Say you have a DTO for an API response:

@Data
public class UserDetailDTO {
    private String id;
    private String name;
    private Integer age;

    @Navigate(value = RelationTypeEnum.ManyToOne)
    private CompanyDTO company;

    @Navigate(value = RelationTypeEnum.OneToMany)
    private List<BankCardDTO> bankCards;
}
Enter fullscreen mode Exit fullscreen mode

You query it like this:

List<UserDetailDTO> result = easyEntityQuery.queryable(SysUser.class)
    .where(user -> user.age().gt(18))
    .selectAutoInclude(UserDetailDTO.class)
    .toList();
Enter fullscreen mode Exit fullscreen mode

That's it. The framework:

  1. Maps all matching fields by name from SysUser to UserDetailDTO
  2. Sees the @Navigate on company and bankCards
  3. Generates separate batch queries (using IN) to load the related data
  4. Assembles the full DTO tree

The SQL looks like:

-- Main query
SELECT t.id, t.name, t.age, t.company_id FROM t_user t WHERE t.age > 18

-- Batch load companies
SELECT t.id, t.name FROM t_company t WHERE t.id IN (?, ?, ?)

-- Batch load bank cards
SELECT t.id, t.uid, t.code, t.type FROM t_bank_card t WHERE t.uid IN (?, ?, ?)
Enter fullscreen mode Exit fullscreen mode

No N+1. No cartesian explosion. No hand-written mapping code.

For .NET developers: this is like AutoMapper.ProjectTo<>(), but with one key difference — you can dynamically filter and sort the nested collections:

List<UserDetailDTO> result = easyEntityQuery.queryable(SysUser.class)
    .include(user -> user.bankCards(), q -> {
        q.where(card -> card.type().eq("savings"))
         .orderBy(card -> card.openTime().desc())
         .limit(5);
    })
    .selectAutoInclude(UserDetailDTO.class)
    .toList();
Enter fullscreen mode Exit fullscreen mode

Try doing that with ProjectTo<>. You can't — at least not without hardcoding the filter into your AutoMapper profile.

Collection Quantifiers: any, all, none

Querying against collections feels natural:

// Users who have at least one savings card
easyEntityQuery.queryable(DocUser.class)
    .where(user -> {
        user.bankCards()
            .where(card -> card.type().eq("savings"))
            .any();
    }).toList();

// Users whose savings cards ALL start with "33123"
easyEntityQuery.queryable(DocUser.class)
    .where(user -> {
        user.bankCards()
            .where(card -> card.type().eq("savings"))
            .all(card -> card.code().startsWith("33123"));
    }).toList();
Enter fullscreen mode Exit fullscreen mode

The all() predicate generates NOT EXISTS (... WHERE NOT (...)), which is the correct SQL translation. There's also none() and notEmptyAll() (non-empty AND all match).

And here's something EF Core doesn't have — you can switch the execution strategy from correlated subqueries to GROUP JOIN with a single config flag:

easyEntityQuery.queryable(DocUser.class)
    .configure(s -> s.getBehavior().add(EasyBehaviorEnum.ALL_SUB_QUERY_GROUP_JOIN))
    .where(user -> {
        user.bankCards().all(card -> card.code().startsWith("33123"));
    }).toList();
Enter fullscreen mode Exit fullscreen mode

This rewrites the NOT EXISTS into a LEFT JOIN (... GROUP BY ...) ... WHERE count = 0 pattern, which can be significantly faster on large datasets.

Window Functions

Something most Java ORMs don't even attempt:

easyEntityQuery.queryable(BlogEntity.class)
    .select(blog -> Select.DRAFT.of(
        blog.title(),
        blog.expression().fx().rowNumber()
            .over()
            .partitionBy(blog.category())
            .orderBy(blog.createTime().desc())
    ))
    .toList();
Enter fullscreen mode Exit fullscreen mode

ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD — they're all there, with type-safe PARTITION BY and ORDER BY.

CTE and Recursive Tree Queries

Got a category table with id and parent_id? One method call:

List<Category> tree = easyEntityQuery.queryable(Category.class)
    .where(c -> c.id().eq("root"))
    .asTreeCTE()
    .toTreeList();
Enter fullscreen mode Exit fullscreen mode

This generates a proper WITH RECURSIVE CTE:

WITH RECURSIVE as_tree_cte AS (
    (SELECT 0 AS cte_deep, t1.id, t1.parent_id, t1.name 
     FROM category t1 WHERE t1.id = ?)
    UNION ALL
    (SELECT t2.cte_deep + 1, t3.id, t3.parent_id, t3.name 
     FROM as_tree_cte t2 
     INNER JOIN category t3 ON t3.parent_id = t2.id)
) 
SELECT t.id, t.parent_id, t.name, t.cte_deep FROM as_tree_cte t
Enter fullscreen mode Exit fullscreen mode

You can also go upward (setUp(true)), limit depth, join CTEs with other tables, and project the result into a DTO with selectAutoInclude.

Raw SQL Escape Hatch

When the DSL doesn't cover your edge case, you can inject SQL fragments anywhere — not just at the query root:

easyEntityQuery.queryable(SysUser.class)
    .where(user -> {
        // Raw SQL in WHERE
        user.expression().rawSQLCommand(
            "FIND_IN_SET({0}, {1})", "admin", user.roles()
        );
    })
    .select(user -> Select.DRAFT.of(
        user.name(),
        // Raw SQL in SELECT
        user.expression().rawSQLStatement(
            "SUBSTR({0}, {1}, {2})", user.idCard(), 1, 4
        ).asStr()
    ))
    .toList();
Enter fullscreen mode Exit fullscreen mode

This is different from EF Core's FromSqlRaw, which only works as the query source. Here you can mix DSL and raw SQL at any level.

Database Support

The framework supports 13 databases through dedicated dialect modules:

MySQL, PostgreSQL, SQL Server, Oracle, SQLite, H2, ClickHouse, DuckDB, DB2, and several Chinese domestic databases (DM, KingbaseES, GaussDB, TSDB).

Getting Started with Spring Boot

<dependency>
    <groupId>com.easy-query</groupId>
    <artifactId>sql-springboot-starter</artifactId>
    <version>LATEST</version>
</dependency>
<dependency>
    <groupId>com.easy-query</groupId>
    <artifactId>sql-api-proxy</artifactId>
    <version>LATEST</version>
</dependency>
<dependency>
    <groupId>com.easy-query</groupId>
    <artifactId>sql-mysql</artifactId>
    <version>LATEST</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode
# application.yml
easy-query:
  enable: true
  database: mysql
  name-conversion: underlined  # camelCase -> snake_case
Enter fullscreen mode Exit fullscreen mode

Then inject and use:

@RestController
@RequiredArgsConstructor
public class UserController {
    private final EasyEntityQuery easyEntityQuery;

    @GetMapping("/users")
    public List<UserDTO> listUsers(@RequestParam(required = false) String name) {
        return easyEntityQuery.queryable(SysUser.class)
            .where(user -> user.name().like(StringUtils.isNotBlank(name), name))
            .selectAutoInclude(UserDTO.class)
            .toList();
    }
}
Enter fullscreen mode Exit fullscreen mode

What's Missing (Honest Take)

No tool is perfect. Coming from EF Core, here's what you won't find:

  • Inheritance mapping (TPH/TPT/TPC) — no discriminator-based table strategies
  • Migrations — there's a basic syncTable for DDL sync, but nothing close to EF Core's incremental migration system with up/down scripts
  • Compiled queries — no query plan caching mechanism
  • INTERSECT / EXCEPT — only UNION is supported as a set operation
  • Temporal tables — no built-in historical data queries
  • Execution retry strategies — no automatic transient fault retry

On the flip side, easy-query has things EF Core doesn't: table/database sharding, read-write splitting, column encryption with LIKE support, and the implicit join system that's genuinely more powerful than EF Core's navigation property translation.

So Who Is This For?

If you're a Java developer tired of writing MyBatis XML or wrestling with JPA Criteria API, and you've looked at the .NET side with envy — this is worth a serious look. The learning curve is minimal if you've used any LINQ-style query builder before.

If you're a .NET developer who occasionally touches Java projects, you'll feel surprisingly at home.

The project is actively maintained, has solid documentation (mostly in Chinese, though the API is self-explanatory), and supports every major database you'd encounter in production.

GitHub: github.com/xuejmnet/easy-query

Docs: www.easy-query.com/easy-query-doc/en

Top comments (0)