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();
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
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;
}
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();
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
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);
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();
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();
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;
}
You query it like this:
List<UserDetailDTO> result = easyEntityQuery.queryable(SysUser.class)
.where(user -> user.age().gt(18))
.selectAutoInclude(UserDetailDTO.class)
.toList();
That's it. The framework:
- Maps all matching fields by name from
SysUsertoUserDetailDTO - Sees the
@NavigateoncompanyandbankCards - Generates separate batch queries (using IN) to load the related data
- 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 (?, ?, ?)
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();
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();
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();
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();
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();
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
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();
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>
# application.yml
easy-query:
enable: true
database: mysql
name-conversion: underlined # camelCase -> snake_case
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();
}
}
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
syncTablefor 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)