Most Java ORMs are built for CRUD. The moment you need window functions, conditional aggregation, or Top-N-per-group queries, you're dropped into raw SQL strings — losing type safety, IDE support, and cross-database portability in one fell swoop.
Easy-Query takes a different approach. It's a Java/Kotlin ORM that treats analytical queries as first-class citizens, with a type-safe, chainable API that covers window functions, CASE WHEN, string aggregation, partition-based filtering, and more — all while generating correct SQL across 12+ database dialects.
This article walks through easy-query's OLAP capabilities with real code examples and the SQL they produce.
Window Functions: Full API, Not String Concatenation
Most ORMs force you to write window functions as raw SQL. Easy-query provides a complete, type-safe API.
ROW_NUMBER / RANK / DENSE_RANK
entityQuery.queryable(BlogEntity.class)
.select(b -> Select.PART.of(
b,
b.expression().rowNumberOver()
.partitionBy(b.title())
.orderBy(b.createTime()),
b.expression().rankOver()
.partitionBy(b.title())
.orderBy(b.createTime()),
b.expression().denseRankOver()
.partitionBy(b.title())
.orderBy(b.createTime())
)).toList();
Generated SQL:
SELECT t.*,
(ROW_NUMBER() OVER (PARTITION BY t.title ORDER BY t.create_time ASC)),
(RANK() OVER (PARTITION BY t.title ORDER BY t.create_time ASC)),
(DENSE_RANK() OVER (PARTITION BY t.title ORDER BY t.create_time ASC))
FROM t_blog t
LAG / LEAD / FIRST_VALUE / LAST_VALUE / NTH_VALUE
entityQuery.queryable(SysBankCard.class)
.select(card -> Select.DRAFT.of(
card.type(),
card.type().offset(o -> {
o.partitionBy(card.bankId());
o.orderBy(card.type());
}).prev(1), // LAG(type, 1)
card.openTime().offset(o -> {
o.orderBy(card.type());
}).next(1), // LEAD(open_time, 1)
card.openTime().offset(o -> {
o.partitionBy(card.bankId());
o.orderBy(card.type());
}).firstValue(), // FIRST_VALUE(open_time)
card.openTime().offset(o -> {
o.partitionBy(card.bankId());
o.orderBy(card.type());
}).nthValue(2) // NTH_VALUE(open_time, 2)
)).toList();
Every part of this — partitionBy, orderBy, column references — is compile-time checked. Misspell a column name and your IDE catches it immediately.
Aggregate Window Functions (SUM / AVG / COUNT OVER)
entityQuery.queryable(BlogEntity.class)
.select(b -> Select.PART.of(
b,
b.expression().sumOver(b.star())
.partitionBy(b.title())
.orderBy(b.createTime())
)).toList();
SELECT t.*,
(SUM(t.star) OVER (PARTITION BY t.title ORDER BY t.create_time ASC))
FROM t_blog t
This gives you running totals, moving averages, and cumulative counts with zero raw SQL.
Filtering on Window Results: The partitionBy Wrapper
A common pain point: you can't use window function results in WHERE directly — SQL requires wrapping in a subquery. Easy-query handles this automatically.
entityQuery.queryable(BlogEntity.class)
.where(b -> b.createTime().gt(someDate))
.select(b -> Select.PART.of(
b,
b.expression().countOver(b.star()).partitionBy(b.title()),
b.expression().rowNumberOver().partitionBy(b.title()).orderBy(b.createTime())
))
// Now you can filter on window results:
.where(partition -> {
partition.partColumn1().lt(10L); // countOver result
partition.partColumn2().eq(1L); // rowNumber result
})
.toList();
SELECT *
FROM (
SELECT t.*,
(COUNT(t.star) OVER (PARTITION BY t.title)) AS __part__column1,
(ROW_NUMBER() OVER (PARTITION BY t.title ORDER BY t.create_time ASC)) AS __part__column2
FROM t_blog t WHERE t.create_time > ?
) t1
WHERE t1.__part__column1 < ? AND t1.__part__column2 = ?
Easy-query automatically wraps the window query in a derived table so you can filter on the results. You never have to think about this.
Top-N Per Group: One Line Instead of Twenty
Getting the "first item per group" is one of the most common analytical patterns — and one of the most tedious to write in SQL.
.first() — Get the First Row Per Group
entityQuery.queryable(SysUser.class)
.where(user ->
user.bankCards()
.orderBy(card -> card.openTime().asc())
.first()
.bank().name().eq("ICBC")
).toList();
SELECT t.* FROM t_sys_user t
LEFT JOIN (
SELECT t1.*,
(ROW_NUMBER() OVER (PARTITION BY t1.uid ORDER BY t1.open_time ASC)) AS __row__
FROM t_bank_card t1
) t3 ON (t3.uid = t.id AND t3.__row__ = 1)
INNER JOIN t_bank t4 ON t4.id = t3.bank_id
WHERE t4.name = ?
One line of Java. Easy-query generates the ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) pattern, wraps it in a derived table, and filters for __row__ = 1 — all automatically.
.elements(from, to) — Get Top-N Per Group
user.bankCards()
.orderBy(card -> card.openTime().asc())
.elements(0, 2)
.joining(card -> card.type(), ",")
This takes the first 3 cards (indices 0, 1, 2) per user, then aggregates their types with GROUP_CONCAT. The generated SQL uses ROW_NUMBER with a range filter under the hood.
Conditional Aggregation: .count().filter()
SQL's COUNT(CASE WHEN ... THEN 1 ELSE NULL END) pattern is verbose. Easy-query wraps it cleanly:
entityQuery.queryable(SysUser.class)
.select(user -> Select.DRAFT.of(
user.id().count()
.filter(() -> user.address().eq("Hangzhou")),
user.id().count()
.filter(() -> user.address().eq("Beijing")),
user.age().avg()
.filter(() -> user.address().eq("Beijing"))
)).toList();
SELECT
COUNT((CASE WHEN t.address = 'Hangzhou' THEN 1 ELSE NULL END)),
COUNT((CASE WHEN t.address = 'Beijing' THEN 1 ELSE NULL END)),
AVG((CASE WHEN t.address = 'Beijing' THEN t.age ELSE NULL END))
FROM t_sys_user t
Three conditional aggregations, zero raw SQL, full type safety.
CASE WHEN Builder
For more complex conditional logic, easy-query provides a full CASE WHEN builder:
entityQuery.queryable(BlogEntity.class)
.groupBy(b -> GroupKeys.of(b.title()))
.select(group -> Select.DRAFT.of(
group.key1(),
group.expression()
.caseWhen(() -> group.groupTable().score().gt(BigDecimal.ZERO))
.then(group.groupTable().score())
.elseEnd(null)
.sum()
)).toList();
SELECT t.title,
SUM(CASE WHEN t.score > 0 THEN t.score ELSE NULL END)
FROM t_blog t
GROUP BY t.title
The builder supports chaining multiple .caseWhen().then() branches, and the result can be used with any aggregate function (.sum(), .count(), .avg(), etc.) or in WHERE clauses.
String Aggregation: Cross-Database .joining()
MySQL has GROUP_CONCAT, PostgreSQL has STRING_AGG, Oracle has LISTAGG. Easy-query abstracts all of them behind .joining():
entityQuery.queryable(BlogEntity.class)
.groupBy(b -> GroupKeys.of(b.title()))
.select(group -> Select.DRAFT.of(
group.key1(),
group.groupTable().content().joining(",")
)).toList();
On MySQL:
SELECT t.title, GROUP_CONCAT(t.content SEPARATOR ',') FROM t_blog t GROUP BY t.title
On PostgreSQL:
SELECT t."title", STRING_AGG((t."content")::TEXT, ',') FROM "t_blog" t GROUP BY t."title"
Same Java code, correct SQL for each database. It also supports DISTINCT and ORDER BY:
bank.bankCards()
.distinct()
.orderBy(card -> card.openTime().desc())
.joining(card -> card.type(), ",")
GROUP_CONCAT(DISTINCT t1.type ORDER BY t1.open_time DESC SEPARATOR ',')
Implicit Collection Aggregation
This is where easy-query's "implicit" philosophy really shines. On a OneToMany/ManyToMany navigation, you can call aggregation methods directly — no manual subquery needed:
entityQuery.queryable(Company.class)
.where(company -> {
// EXISTS subquery — auto-generated
company.users().any(u -> u.name().like("John"));
// COUNT subquery
company.users()
.where(u -> u.age().gt(30))
.count().ge(5L);
// MAX subquery
company.users()
.max(u -> u.salary())
.gt(100000);
}).toList();
Each of these generates the appropriate subquery:
WHERE
EXISTS (SELECT 1 FROM t_user t1 WHERE t1.company_id = t.id AND t1.name LIKE ?)
AND (SELECT COUNT(*) FROM t_user t1 WHERE t1.company_id = t.id AND t1.age > ?) >= ?
AND (SELECT MAX(t1.salary) FROM t_user t1 WHERE t1.company_id = t.id) > ?
You think in objects. Easy-query thinks in SQL.
subQueryToGroupJoin: The N+1 Killer for Analytics
When you reference multiple aggregations on the same collection in one query, easy-query can merge them into a single LEFT JOIN (SELECT ... GROUP BY ...) instead of multiple correlated subqueries:
entityQuery.queryable(SysUser.class)
.subQueryToGroupJoin(user -> user.bankCards())
.where(user -> {
user.bankCards()
.where(card -> card.bank().name().eq("ICBC"))
.count().ge(2L);
user.bankCards()
.none(card -> card.bank().name().eq("CCB"));
}).toList();
Without subQueryToGroupJoin, this would generate two separate correlated subqueries. With it:
SELECT t.* FROM t_sys_user t
LEFT JOIN (
SELECT t1.uid AS __group_key1__,
COUNT((CASE WHEN t3.name = 'ICBC' THEN 1 ELSE NULL END)) AS __count2__,
(COUNT((CASE WHEN t3.name = 'CCB' THEN 1 ELSE NULL END)) <= 0) AS __none3__
FROM t_bank_card t1
INNER JOIN t_bank t3 ON t3.id = t1.bank_id
GROUP BY t1.uid
) t2 ON t2.__group_key1__ = t.id
WHERE IFNULL(t2.__count2__, 0) >= 2
AND IFNULL(t2.__none3__, true) = true
One GROUP BY subquery replaces multiple correlated subqueries. This is a significant performance optimization for analytical workloads.
Date Functions
// Date arithmetic
o.createTime().plus(2, TimeUnitEnum.DAYS)
o.createTime().plusMonths(12)
// Date difference
o.updateTime().duration(o.createTime()).toDays()
o.updateTime().duration(o.createTime()).toHours()
// Formatting
o.createTime().format("yyyy/MM/dd")
All date functions are dialect-aware. plus(2, DAYS) generates DATE_ADD(t.create_time, INTERVAL 2 DAY) on MySQL and t.create_time + INTERVAL '2 DAY' on PostgreSQL.
Raw SQL Escape Hatch
For the rare cases where the built-in API doesn't cover your needs:
group.expression().rawSQLStatement(
"round(100 - (sum({0}) + sum({1})) * 100.0 / nullif(sum({2}), 0)::numeric, 3)",
column2, column3, column4
).asBigDecimal().as("result")
{0}, {1}, {2} are placeholder references to type-safe column expressions. You get raw SQL flexibility without losing parameter binding safety.
Supported Databases
All the OLAP features above work across:
| Database | Dialect Handling |
|---|---|
| MySQL 5.x / 8.x |
GROUP_CONCAT, backtick quoting |
| PostgreSQL |
STRING_AGG, ::TEXT casting, double-quote quoting |
| Oracle |
LISTAGG, Oracle-specific date arithmetic |
| SQL Server |
STRING_AGG, [bracket] quoting |
| SQLite | Limited window function support |
| ClickHouse | ClickHouse-specific aggregation |
| H2 | H2 syntax |
| DM (达梦) | DM-specific dialect |
| KingbaseES | KingbaseES dialect |
| GaussDB | GaussDB dialect |
| DuckDB | DuckDB dialect |
| DB2 | DB2 dialect |
You write once. Easy-query generates the correct SQL for each database.
The Big Picture
Here's what makes easy-query's OLAP support stand out compared to other Java ORMs:
| Capability | Easy-Query | JPA/Hibernate | JOOQ |
|---|---|---|---|
| Window functions (type-safe API) | Yes | No | Yes |
| LAG/LEAD/FIRST_VALUE | Yes | No | Yes |
Conditional aggregation (.filter()) |
Yes | No | Yes |
| String aggregation (cross-DB) | Yes | No | Yes |
| Top-N per group (one-liner) | Yes | No | No |
| Implicit collection aggregation | Yes | No | No |
| subQueryToGroupJoin optimization | Yes | No | No |
| CASE WHEN builder | Yes | Criteria API | Yes |
| Zero-config dialect adaptation | Yes | Partial | Yes |
Easy-query matches JOOQ's analytical depth while adding higher-level abstractions (.first(), .elements(), .any(), subQueryToGroupJoin) that JOOQ doesn't have.
Getting Started
<dependency>
<groupId>com.easy-query</groupId>
<artifactId>sql-springboot-starter</artifactId>
<version>3.2.1</version>
</dependency>
<!-- Add your database dialect -->
<dependency>
<groupId>com.easy-query</groupId>
<artifactId>sql-mysql</artifactId>
<version>3.2.1</version>
</dependency>
Documentation: https://www.easy-query.com/easy-query-doc/en
GitHub: https://github.com/xuejmnet/easy-query
Conclusion
If your Java application does anything beyond basic CRUD — dashboards, reports, leaderboards, time-series analysis, or any form of data aggregation — easy-query gives you a type-safe, cross-database API that eliminates the need for raw SQL in analytical queries. The implicit collection aggregation and subQueryToGroupJoin optimization are particularly unique features that no other Java ORM offers.
Stop writing GROUP_CONCAT by hand. Your ORM should handle that.
Top comments (0)