DEV Community

Li
Li

Posted on

easy-query: A Type-Safe Java ORM That Actually Handles the Hard Parts

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

  1. Setup
  2. Entity Definition & Proxy Generation
  3. Querying: The Chainable DSL
  4. Implicit Relationships
  5. Projections & VO Mapping
  6. CRUD Operations
  7. Change Tracking & Differential Updates
  8. OLAP & Analytics
  9. Logical Delete
  10. Interceptors & Multi-Tenancy
  11. Column Encryption
  12. Dynamic Table Names
  13. Sharding
  14. Pagination
  15. Code First
  16. 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>
Enter fullscreen mode Exit fullscreen mode
easy-query:
  enable: true
  database: mysql
  print-sql: true
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode
SELECT * FROM t_user t WHERE t.name LIKE '%John%' ORDER BY t.create_time DESC LIMIT 1
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode
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

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();
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

.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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode
SELECT t.id, t.name, t.phone FROM t_user t
Enter fullscreen mode Exit fullscreen mode

Exclude Columns

.select(u -> u.FETCHER.allFieldsExclude(u.phone(), u.createTime()))
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Transactions

try (Transaction transaction = easyEntityQuery.beginTransaction()) {
    easyEntityQuery.insertable(user).executeRows();
    easyEntityQuery.insertable(bankCards).batch().executeRows();
    transaction.commit();
}
// Auto-rollback if commit() is not called
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Conditional Aggregation

user.id().count().filter(() -> user.address().eq("Hangzhou"))
// → COUNT(CASE WHEN t.address = 'Hangzhou' THEN 1 ELSE NULL END)
Enter fullscreen mode Exit fullscreen mode

String Aggregation (Cross-Database)

group.groupTable().content().joining(",")
// MySQL → GROUP_CONCAT(t.content SEPARATOR ',')
// PostgreSQL → STRING_AGG(t.content::TEXT, ',')
Enter fullscreen mode Exit fullscreen mode

CASE WHEN Builder

group.expression()
    .caseWhen(() -> group.groupTable().score().gt(0))
    .then(group.groupTable().score())
    .elseEnd(null)
    .sum()
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)