There's a class of problems every Java backend developer eventually hits: your ORM handles simple CRUD just fine, but the moment you need sharding, window functions, conditional aggregation, multi-tenancy filtering, or differential updates, you're either writing raw SQL or fighting the framework.
Easy-Query is a Java/Kotlin ORM designed from the ground up to handle these "hard parts" with a type-safe, chainable API. It supports 12+ databases, requires zero XML configuration, and provides capabilities that go well beyond what JPA/Hibernate or JOOQ offer out of the box.
This article is a comprehensive tour of what easy-query can do.
Table of Contents
- Setup
- Entity Definition & Proxy Generation
- Querying: The Chainable DSL
- Implicit Relationships
- Projections & VO Mapping
- CRUD Operations
- Change Tracking & Differential Updates
- OLAP & Analytics
- Logical Delete
- Interceptors & Multi-Tenancy
- Column Encryption
- Dynamic Table Names
- Sharding
- Pagination
- Code First
- Supported Databases
Setup
Spring Boot:
<dependency>
<groupId>com.easy-query</groupId>
<artifactId>sql-springboot-starter</artifactId>
<version>3.2.1</version>
</dependency>
<dependency>
<groupId>com.easy-query</groupId>
<artifactId>sql-mysql</artifactId>
<version>3.2.1</version>
</dependency>
easy-query:
enable: true
database: mysql
print-sql: true
That's it. No XML mapping files, no persistence.xml, no session factory boilerplate.
Entity Definition & Proxy Generation
Entities are plain Java classes annotated with @Table and @EntityProxy:
@Data
@Table("t_user")
@EntityProxy
public class SysUser implements ProxyEntityAvailable<SysUser, SysUserProxy> {
@Column(primaryKey = true)
private String id;
private String name;
private Integer age;
private String phone;
private LocalDateTime createTime;
@Navigate(value = RelationTypeEnum.ManyToOne, selfProperty = "companyId")
private Company company;
@Navigate(value = RelationTypeEnum.OneToMany, targetProperty = "userId")
private List<SysBankCard> bankCards;
@Navigate(value = RelationTypeEnum.ManyToMany,
mappingClass = UserRole.class,
selfMappingProperty = "userId",
targetMappingProperty = "roleId")
private List<SysRole> roles;
}
At compile time, APT generates a SysUserProxy class that provides type-safe column accessors (user.name(), user.age(), etc.). You never write string-based column references.
Relationship Types
| Annotation | Behavior |
|---|---|
@Navigate(ManyToOne) |
Implicit JOIN when you access user.company().name()
|
@Navigate(OneToMany) |
Implicit subquery: user.bankCards().any(...)
|
@Navigate(ManyToMany) |
Implicit middle table handling |
@Navigate(OneToOne) |
Implicit JOIN, similar to ManyToOne |
You declare relationships once. Easy-query decides whether to use JOINs or subqueries based on the relationship type.
Querying: The Chainable DSL
Basic Query
SysUser user = easyEntityQuery.queryable(SysUser.class)
.where(u -> u.name().like("John"))
.orderBy(u -> u.createTime().desc())
.firstOrNull();
SELECT * FROM t_user t WHERE t.name LIKE '%John%' ORDER BY t.create_time DESC LIMIT 1
Multi-Table JOIN
List<SysUser> users = easyEntityQuery.queryable(SysUser.class)
.leftJoin(Company.class, (u, c) -> u.companyId().eq(c.id()))
.where((u, c) -> c.name().like("Acme"))
.toList();
JOIN with Subquery
Any queryable can be used as a JOIN target — including subqueries and CTEs:
var subQuery = easyEntityQuery.queryable(SysBankCard.class)
.groupBy(card -> GroupKeys.of(card.bankId()))
.select(group -> Select.DRAFT.of(
group.key1(),
group.count()
));
List<Draft2<String, Long>> result = easyEntityQuery.queryable(SysBank.class)
.leftJoin(subQuery, (bank, cardGroup) -> bank.id().eq(cardGroup.value1()))
.select((bank, cardGroup) -> Select.DRAFT.of(
bank.name(),
cardGroup.value2()
)).toList();
SELECT t.name, t1.value2
FROM t_bank t
LEFT JOIN (
SELECT t2.bank_id AS value1, COUNT(*) AS value2
FROM t_bank_card t2 GROUP BY t2.bank_id
) t1 ON t.id = t1.value1
Implicit Relationships
This is easy-query's most distinctive feature. When you access a navigation property in a query, the framework automatically generates the appropriate SQL pattern:
ManyToOne / OneToOne → Implicit JOIN
easyEntityQuery.queryable(SysUser.class)
.where(user -> user.company().name().like("Acme"))
.orderBy(user -> user.company().registerMoney().desc())
.toList();
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
You wrote user.company().name(). Easy-query generated the JOIN.
OneToMany → Implicit Subquery
easyEntityQuery.queryable(Company.class)
.where(company -> {
company.users().any(u -> u.name().like("John"));
company.users().where(u -> u.age().gt(30)).count().ge(5L);
}).toList();
SELECT t.* FROM t_company t
WHERE EXISTS (SELECT 1 FROM t_user t1 WHERE t1.company_id = t.id AND t1.name LIKE '%John%')
AND (SELECT COUNT(*) FROM t_user t1 WHERE t1.company_id = t.id AND t1.age > 30) >= 5
.any() becomes EXISTS. .count() becomes a scalar subquery. No manual SQL required.
Top-N Per Group — .first() and .elements()
easyEntityQuery.queryable(SysUser.class)
.where(user ->
user.bankCards()
.orderBy(card -> card.openTime().desc())
.first()
.type().eq("credit")
).toList();
Easy-query automatically generates a derived table with ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) and filters for __row__ = 1. One line of Java replaces ~20 lines of SQL.
Projections & VO Mapping
Select Specific Columns
easyEntityQuery.queryable(SysUser.class)
.select(u -> u.FETCHER.id().name().phone())
.toList();
SELECT t.id, t.name, t.phone FROM t_user t
Exclude Columns
.select(u -> u.FETCHER.allFieldsExclude(u.phone(), u.createTime()))
Map to VO
easyEntityQuery.queryable(SysUser.class)
.leftJoin(SysBankCard.class, (u, card) -> u.id().eq(card.uid()))
.select(BankCardVO.class, (u, card) -> Select.of(
card.FETCHER.allFields(),
u.name().as(BankCardVO.Fields.userName)
)).toList();
Draft Tuples
For ad-hoc projections without defining a VO class:
List<Draft3<String, Integer, LocalDateTime>> result = easyEntityQuery
.queryable(BlogEntity.class)
.groupBy(b -> GroupKeys.of(b.title()))
.select(group -> Select.DRAFT.of(
group.key1(),
group.groupTable().star().sum(),
group.groupTable().createTime().max()
)).toList();
Eager Loading with include / selectAutoInclude
// Explicit include
easyEntityQuery.queryable(SysUser.class)
.include(user -> user.bankCards())
.include(user -> user.roles())
.toList();
// Auto include for VO (loads relations declared on the VO class)
easyEntityQuery.queryable(SysUser.class)
.selectAutoInclude(SysUserDetailVO.class)
.toList();
// Include with selective columns
easyEntityQuery.queryable(SysUser.class)
.include(user -> user.bankCards(), then -> {
then.select(card -> card.FETCHER.id().type());
}).toList();
CRUD Operations
Insert
// Single
SysUser user = new SysUser();
user.setId(UUID.randomUUID().toString());
user.setName("John");
user.setAge(30);
easyEntityQuery.insertable(user).executeRows();
// Batch
List<SysUser> users = buildUserList();
easyEntityQuery.insertable(users).batch().executeRows();
Update
// Entity update (updates all non-null fields)
SysUser user = easyEntityQuery.queryable(SysUser.class)
.where(u -> u.id().eq("123"))
.firstNotNull("User not found");
user.setName("Jane");
easyEntityQuery.updatable(user).executeRows();
// Expression update
easyEntityQuery.updatable(SysUser.class)
.setColumns(u -> {
u.name().set("Jane");
u.age().set(u.age().plus(1)); // age = age + 1
})
.where(u -> u.id().eq("123"))
.executeRows();
Delete
// By entity
easyEntityQuery.deletable(user).executeRows();
// By condition
easyEntityQuery.deletable(SysUser.class)
.where(u -> u.name().eq("obsolete"))
.executeRows();
// By ID
easyEntityQuery.deletable(SysUser.class)
.whereById("123")
.executeRows();
Upsert (Insert or Update on Conflict)
easyEntityQuery.insertable(entity)
.onConflictThen(o -> o.FETCHER.allFields())
.executeRows();
// With specific conflict columns
easyEntityQuery.insertable(entity)
.onConflictThen(o -> o.FETCHER.allFields(), o -> o.uniqueKey())
.executeRows();
Transactions
try (Transaction transaction = easyEntityQuery.beginTransaction()) {
easyEntityQuery.insertable(user).executeRows();
easyEntityQuery.insertable(bankCards).batch().executeRows();
transaction.commit();
}
// Auto-rollback if commit() is not called
Change Tracking & Differential Updates
Easy-query can track entity changes and only update modified columns:
TrackManager trackManager = easyEntityQuery.getRuntimeContext().getTrackManager();
try {
trackManager.begin();
SysUser user = easyEntityQuery.queryable(SysUser.class)
.where(u -> u.id().eq("123"))
.firstNotNull("not found");
easyEntityQuery.addTracking(user);
// Only this field changed
user.setName("New Name");
// Generates: UPDATE t_user SET name = ? WHERE id = ?
// NOT: UPDATE t_user SET name = ?, age = ?, phone = ?, ... WHERE id = ?
easyEntityQuery.updatable(user).executeRows();
} finally {
trackManager.release();
}
Only the name column is included in the SET clause. This reduces write amplification and avoids overwriting concurrent changes to other columns.
OLAP & Analytics
Easy-query has first-class support for analytical queries. Here's a quick overview (see the companion OLAP article for the full deep dive).
Window Functions
b.expression().rowNumberOver().partitionBy(b.title()).orderBy(b.createTime())
b.expression().rankOver().partitionBy(b.title()).orderBy(b.createTime())
b.expression().sumOver(b.star()).partitionBy(b.title()).orderBy(b.createTime())
card.type().offset(o -> o.orderBy(card.type())).prev(1) // LAG
card.type().offset(o -> o.orderBy(card.type())).next(1) // LEAD
Conditional Aggregation
user.id().count().filter(() -> user.address().eq("Hangzhou"))
// → COUNT(CASE WHEN t.address = 'Hangzhou' THEN 1 ELSE NULL END)
String Aggregation (Cross-Database)
group.groupTable().content().joining(",")
// MySQL → GROUP_CONCAT(t.content SEPARATOR ',')
// PostgreSQL → STRING_AGG(t.content::TEXT, ',')
CASE WHEN Builder
group.expression()
.caseWhen(() -> group.groupTable().score().gt(0))
.then(group.groupTable().score())
.elseEnd(null)
.sum()
subQueryToGroupJoin — Merge Multiple Subqueries into One GROUP BY
easyEntityQuery.queryable(SysUser.class)
.subQueryToGroupJoin(user -> user.bankCards())
.where(user -> {
user.bankCards().where(c -> c.type().eq("credit")).count().ge(2L);
user.bankCards().none(c -> c.type().eq("debit"));
}).toList();
Instead of generating two correlated subqueries, easy-query merges them into a single LEFT JOIN (SELECT ... GROUP BY ...).
Logical Delete
Add @LogicDelete to a column, and easy-query automatically filters deleted records in all queries:
@Table("t_blog")
@EntityProxy
public class BlogEntity implements ProxyEntityAvailable<BlogEntity, BlogEntityProxy> {
@Column(primaryKey = true)
private String id;
private String title;
@LogicDelete(strategy = LogicDeleteStrategyEnum.BOOLEAN)
private Boolean deleted;
}
Now every query on BlogEntity automatically appends WHERE t.deleted = false. Delete operations set deleted = true instead of issuing DELETE FROM.
You can customize the strategy — timestamp-based, integer-based, or entirely custom:
@LogicDelete(strategy = LogicDeleteStrategyEnum.LOCAL_DATE_TIME)
private LocalDateTime deleteAt;
When "deleted", deleteAt is set to LocalDateTime.now(). Queries filter by WHERE delete_at IS NULL.
Interceptors & Multi-Tenancy
Entity Interceptor (Auto-Fill Fields)
public class MyEntityInterceptor implements EntityInterceptor {
@Override
public void configureInsert(Class<?> entityClass,
EntityInsertExpressionBuilder builder, Object entity) {
TopicInterceptor topic = (TopicInterceptor) entity;
if (topic.getCreateTime() == null) {
topic.setCreateTime(LocalDateTime.now());
}
if (topic.getCreateBy() == null) {
topic.setCreateBy(CurrentUserHelper.getUserId());
}
}
}
Every insert automatically fills createTime and createBy without manual code in each service method.
Predicate Interceptor (Multi-Tenancy)
public class MyTenantInterceptor implements PredicateFilterInterceptor {
@Override
public void configure(Class<?> entityClass,
LambdaEntityExpressionBuilder builder,
WherePredicate<Object> wherePredicate) {
wherePredicate.eq("tenantId", CurrentUserHelper.getTenantId());
}
}
Every query on entities with the tenant interceptor automatically appends WHERE tenant_id = ?. You never forget to add the tenant filter.
Column Encryption
Java-Level Encryption
@Table("t_user")
@EntityProxy
public class SysUserEncryption {
@Encryption(strategy = Base64EncryptionStrategy.class)
private String phone;
@Encryption(strategy = MyEncryptionStrategy.class, supportQueryLike = true)
private String address;
}
Values are encrypted before writing to the database and decrypted after reading. supportQueryLike = true enables LIKE queries on encrypted columns (using a compatible encryption strategy).
SQL-Level Encryption
@Column(sqlConversion = MySQLAesEncryptColumnValueSQLConverter.class)
private String phone;
This generates AES_ENCRYPT / AES_DECRYPT calls in the SQL itself, so encryption happens at the database level.
Dynamic Table Names
For multi-tenancy with table-per-tenant or time-partitioned tables:
easyEntityQuery.queryable(SysUser.class)
.asTable("t_user_2024")
.where(u -> u.name().like("John"))
.toList();
// Or with a function
easyEntityQuery.updatable(SysUser.class)
.asTable(name -> name + "_archive")
.whereById("1")
.executeRows();
Sharding
Easy-query supports table sharding, database sharding, and combined table+database sharding.
Table Sharding
Define the shard initializer:
public class UserShardingInitializer implements EntityShardingInitializer<SysUser> {
@Override
public void configure(ShardingEntityBuilder<SysUser> builder) {
List<String> tables = new ArrayList<>();
for (int i = 0; i < 4; i++) {
tables.add("t_user_" + i);
}
LinkedHashMap<String, Collection<String>> initTables = new LinkedHashMap<>();
initTables.put("ds_default", tables);
builder.actualTableNameInit(initTables);
}
}
Define the routing rule:
public class UserTableRoute extends AbstractShardingTableRoute<SysUser> {
@Override
protected RouteFunction<ActualTable> getRouteFilter(
TableAvailable table, Object shardingValue,
ShardingOperatorEnum op, boolean withEntity) {
int shard = Math.abs(shardingValue.hashCode()) % 4;
String target = table.getTableName() + "_" + shard;
return t -> target.equalsIgnoreCase(t.getActualTableName());
}
}
Queries automatically route to the correct shard table based on the sharding key. Cross-shard queries are supported — easy-query queries all relevant shards and merges the results.
Database Sharding
Same pattern, but with multiple ShardingDataSource instances:
Set<ShardingDataSource> dataSources = new HashSet<>();
dataSources.add(new ShardingDataSource("ds2021", dataSource2021, 20));
dataSources.add(new ShardingDataSource("ds2022", dataSource2022, 20));
dataSources.add(new ShardingDataSource("ds2023", dataSource2023, 20));
EasyQueryShardingOption option = new EasyQueryShardingOption(dataSources);
Pagination
// Simple pagination
EasyPageResult<SysUser> page = easyEntityQuery.queryable(SysUser.class)
.where(u -> u.age().gt(18))
.orderBy(u -> u.createTime().desc())
.toPageResult(1, 20); // page 1, 20 per page
long total = page.getTotal();
List<SysUser> data = page.getData();
For sharded tables, pagination works correctly across shards — easy-query handles the cross-shard result merging and total count.
Code First
Generate database tables from your entity classes:
// Sync specific classes
CodeFirstCommand command = databaseCodeFirst.syncTableCommand(
Arrays.asList(SysUser.class, SysBank.class, SysBankCard.class)
);
command.executeWithTransaction(sql -> {
System.out.println(sql.getSQL());
sql.commit();
});
// Sync all entities in a package
easyQueryClient.syncTableByPackage(10, "com.myapp.entity");
Easy-query reads the @Table, @Column, and relationship annotations to generate CREATE TABLE / ALTER TABLE DDL for your target database.
Supported Databases
| Database | Module |
|---|---|
| MySQL 5.x / 8.x | sql-mysql |
| PostgreSQL | sql-pgsql |
| Oracle | sql-oracle |
| SQL Server | sql-mssql |
| SQLite | sql-sqlite |
| H2 | sql-h2 |
| ClickHouse | sql-clickhouse |
| DM (达梦) | sql-dameng |
| KingbaseES | sql-kingbase-es |
| GaussDB | sql-gauss-db |
| DuckDB | sql-duckdb |
| DB2 | sql-db2 |
All dialect differences (identifier quoting, pagination syntax, date functions, string aggregation functions, etc.) are handled automatically.
What Makes Easy-Query Different
Here's a summary of what sets easy-query apart from other Java ORMs:
vs. JPA/Hibernate
| Easy-Query | JPA/Hibernate | |
|---|---|---|
| Query style | Type-safe chainable DSL | JPQL strings or Criteria API |
| Window functions | Full API | Not supported |
| Implicit relationships | JOIN/subquery auto-generated | Lazy loading (N+1 prone) |
| Sharding | Built-in | Requires external solutions |
| Logical delete | @LogicDelete |
Manual implementation |
| Multi-tenancy | Predicate interceptor |
@Filter (limited) |
| Conditional aggregation | .count().filter() |
Not supported |
| Change tracking | Built-in differential updates | Dirty checking (all columns) |
vs. JOOQ
| Easy-Query | JOOQ | |
|---|---|---|
| Code generation | APT (compile-time) | Requires database connection |
| Implicit relationships | Yes (auto JOIN/subquery) | No (manual JOINs) |
| Top-N per group |
.first() one-liner |
Manual window function |
subQueryToGroupJoin |
Yes | No |
| Sharding | Built-in | Not supported |
| Logical delete | Built-in | Not supported |
| Navigation properties | @Navigate |
Not supported |
| ORM features (include, tracking) | Yes | No (query builder only) |
Easy-query occupies a unique position: it has the ORM convenience of JPA (entities, relationships, eager loading, change tracking) combined with the query power of JOOQ (type-safe DSL, window functions, CTEs), plus features neither has (implicit relationships, sharding, Top-N shortcuts).
Getting Started
<dependency>
<groupId>com.easy-query</groupId>
<artifactId>sql-springboot-starter</artifactId>
<version>3.2.1</version>
</dependency>
- Documentation: https://www.easy-query.com/easy-query-doc/en
- GitHub: https://github.com/xuejmnet/easy-query
- License: Apache 2.0
Conclusion
Easy-query is what happens when an ORM takes analytical queries, sharding, and developer ergonomics seriously from day one. Instead of treating complex SQL as an escape hatch, it makes those patterns first-class citizens of a type-safe API.
If you're building Java applications that go beyond CRUD — dashboards, reports, multi-tenant SaaS, sharded databases, or anything involving window functions and conditional aggregation — easy-query is worth a serious look.
Top comments (0)