当在SQL中对结果进行分页时,我们使用标准的SQLOFFSET .. FETCH
或供应商的特定版本,如LIMIT .. OFFSET
。比如说:
SELECT first_name, last_name
FROM actor
ORDER BY actor_id
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY
像往常一样,我们在这个例子中使用Sakila数据库。
这是很直接的。它将给我们N个页面中的第2页,页面大小为10。但我们如何计算这些数值?我们怎么知道我们是在第2页?我们怎么知道第几页N
?我们能不能在不进行额外的往返计算的情况下,例如计算演员的总人数:
-- Yuck, a second round-trip!
SELECT COUNT(*)
FROM actor
我们可以用一个SQL查询和窗口函数来做,但在我解释如何做之前,请考虑阅读这篇文章:为什么OFFSET
分页对你的性能是一件坏事?
如果你仍然相信OFFSET
分页是你需要的,而不是keyset分页,让我们看看如何用SQL计算上述元数据。
我们需要什么元数据?
我们通常需要使用OFFSET
分页的元数据是这些:
TOTAL_ROWS
:如果我们没有分页,记录的总数CURRENT_PAGE
:当前我们所处的页面MAX_PAGE_SIZE
:最大的页面大小ACTUAL_PAGE_SIZE
:实际页面大小(当在最后一页时)ROW
:返回的行的实际偏移量LAST_PAGE
:我们是否在最后一页
最大页面大小是我们在查询中设置的,所以它不需要被计算。其他的都需要计算。下面是如何在一个单一的查询中做到这一点的
SELECT
t.first_name,
t.last_name,
-- Calculate some meta data
COUNT(*) OVER () AS actual_page_size,
MAX(row) OVER () = total_rows AS last_page,
-- Metadata from the subquery
total_rows,
row,
((row - 1) / :max_page_size) + 1 AS current_page
FROM (
SELECT
u.*,
-- Calculate some meta data, repeating the ORDER BY from
-- the original query
COUNT(*) OVER () AS total_rows,
ROW_NUMBER () OVER (ORDER BY u.actor_id) AS row
-- Original query with all the predicates, joins, as a derived table
FROM (
SELECT *
FROM actor
) AS u
-- Ordering and pagination done here, where :offset is
-- The maximum row value of the previous page + 1
ORDER BY u.actor_id
OFFSET :offset ROWS
FETCH NEXT :max_page_size ROWS ONLY
) AS t
ORDER BY t.actor_id
就这样了。令人印象深刻吗?不要害怕,我将一步一步地指导你完成这些事情。如果你曾经对SQL语法感到困惑,可以考虑这篇文章,它解释了SQL操作的逻辑顺序,对于我们的例子来说,这就是:
FROM
(派生表的递归顺序)WHERE
(这个例子省略了)WINDOW
计算SELECT
(投影)ORDER BY
OFFSET .. FETCH
注释我们的查询,将操作的逻辑顺序定为:1.1,1.2,2.1,2.2,2.3,2.4,2.5,3.1,3.2,3.3,3.4:
-- 3.3
SELECT
t.first_name,
t.last_name,
-- 3.2
COUNT(*) OVER () AS actual_page_size,
MAX(row) OVER () = total_rows AS last_page,
-- 3.3
total_rows,
row,
((row - 1) / :max_page_size) + 1 AS current_page
-- 3.1
FROM (
-- 2.3
SELECT
u.*,
-- 2.2
COUNT(*) OVER () AS total_rows,
ROW_NUMBER () OVER (ORDER BY u.actor_id) AS row
-- 2.1
FROM (
-- 1.2
SELECT *
-- 1.1
FROM actor
) AS u
-- 2.4
ORDER BY u.actor_id
-- 2.5
OFFSET :offset ROWS
FETCH NEXT :max_page_size ROWS ONLY
) AS t
-- 3.4
ORDER BY t.actor_id
一步一步的解释
首先,原始查询SELECT * FROM actor
,被包装成一个名为u
的派生表。你几乎可以对这个原始查询做任何事情,只需应用一些转换:
- 1.1, 1.2, 2.1:你 需要投射(
SELECT
子句)你的原始查询所投射的列,加上你需要的列ORDER BY
。因为我在最外层的查询中投射了正确的东西,而且在原始查询中没有DISTINCT
子句,所以我很方便地投射了*
。另外,我还可以投射FIRST_NAME
、LAST_NAME
(因为在原始查询中已经投射了)和ACTOR_ID
(因为这就是我们的ORDER BY
)。 - 2.2:在 那个派生表
u
,我们现在能够计算一些元数据,包括TOTAL_ROWS
作为COUNT(*) OVER ()
,ROW
作为ROW_NUMBER () OVER (ORDER BY t.actor_id)
。COUNT(*) OVER ()
窗口函数有一个空的窗口规范OVER ()
,这意味着它计算所有由FROM
,WHERE
,GROUP BY
,HAVING
子句产生的行,即在我们的特定例子中由u
。没有第二次往返!ROW_NUMBER () OVER (ORDER BY u.actor_id)
将u
中的所有行按u.actor_id
排序,并根据该排序给它们分配唯一的行号。 - 2.3:窗 口函数是隐式计算的,因为它们位于这个派生表的投影中。我们也将再次方便地从
u.*
,因为最外层的查询是明确地投影列。 - 2.4:原来的排序被移到了这里,因为如果我们对
u
的内容进行排序,就不能保证排序会被保持。但是我们需要这个排序来计算OFFSET .. FETCH
之后的内容。 - 2.5:这里是我们分页的地方。
OFFSET
对应于我们之前遇到的最大ROW
值。我们从0
,在页面大小为15
,我们在下一页使用15
。记住,在SQL中索引是基于1
,而OFFSET
是基于0
。 - 3.1:以上所有的内容又被包裹在一个派生表中,以便对其进行进一步的计算,即。
- 3.2:我们可以再次计算
COUNT(*) OVER ()
,计算由FROM
,WHERE
,GROUP BY
,HAVING
子句产生的总行数,即在我们的特定例子中由t
。这一次,行数不能超过MAX_PAGE_SIZE
,因为这是FETCH
(或LIMIT
)子句在t
中所说的。但是也可以更少,所以这就是我们用来计算ACTUAL_PAGE_SIZE
。最后,我们比较MAX(row) OVER () = total_rows
,看看我们是否在最后一页,也就是说,将t
所产生的当前页中row
的最高值与总行数进行比较。另一种计算LAST_PAGE
值的方法是如果ACTUAL_PAGE_SIZE < MAX_PAGE_SIZE
,即COUNT(*) OVER () < :MAX_PAGE_SIZE
。 - 3.3:除了对原始列
FIRST_NAME
,LAST_NAME
(我们现在不再推算*
!)的常规推算外,我们还要做一些最后的计算,包括除以ROW / TOTAL_ROWS
,得到页码。你可以计算更多的东西,比如TOTAL_ROWS / MAX_PAGE_SIZE
,得到TOTAL_PAGES
的值。 - 3.4最 后,我们必须再次
ORDER BY t.actor_id
,不要让任何人告诉你。在SQL中,如果你不ORDER BY
,那么排序就没有定义。当然,如果优化器没有任何好的理由就重新排序,那就太傻了。我们已经在2.4中对子查询的内容进行了排序,但是并不能保证这个排序是稳定的。只要在你的查询中添加DISTINCT
,UNION
, 或者一个JOIN
,导致一个哈希连接或者一些随机的其他运算符,就会破坏排序。所以,如果排序对你来说很重要的话,一定要用ORDER BY
。
我们就完成了!
如何在jOOQ中做到这一点?
这就是jOOQ真正闪亮的用例,因为所有这些都是关于动态SQL。你的实际业务逻辑包含在深度嵌套的u
表中。其他的都是 "表现逻辑",由于非常明显的原因,这些都是用SQL实现的。为了提高性能。
因为你想在你的某个库中只实现一次所有这些,而不是在每次查询时都要玩这个游戏,所以你要使这种查询成为动态的。该实用程序将看起来像这样:
// Assuming as always the usual static imports, including:
// import static org.jooq.impl.DSL.*;
// import com.generated.code.Tables.*;
static Select<?> paginate(
DSLContext ctx,
Select<?> original,
Field<?>[] sort,
int limit,
int offset
) {
Table<?> u = original.asTable("u");
Field<Integer> totalRows = count().over().as("total_rows");
Field<Integer> row = rowNumber().over().orderBy(u.fields(sort))
.as("row");
Table<?> t = ctx
.select(u.asterisk())
.select(totalRows, row)
.from(u)
.orderBy(u.fields(sort))
.limit(limit)
.offset(offset)
.asTable("t");
Select<?> result = ctx
.select(t.fields(original.getSelect().toArray(Field[]::new)))
.select(
count().over().as("actual_page_size"),
field(max(t.field(row)).over().eq(t.field(totalRows)))
.as("last_page"),
t.field(totalRows),
t.field(row),
t.field(row).minus(inline(1)).div(limit).plus(inline(1))
.as("current_page"))
.from(t)
.orderBy(t.fields(sort));
// System.out.println(result);
return result;
}
注意到用于调试的println了吗?它将再次打印出类似于我们的原始查询的东西(但你也会在你的调试日志输出中看到,默认情况下,用jOOQ):
select
t.ACTOR_ID,
t.FIRST_NAME,
t.LAST_NAME,
count(*) over () as actual_page_size,
(max(t.row) over () = t.total_rows) as last_page,
t.total_rows,
t.row,
((t.row / 15) + 1) as current_page
from (
select
u.*,
count(*) over () as total_rows,
row_number() over (order by u.ACTOR_ID) as row
from (
select
ACTOR.ACTOR_ID,
ACTOR.FIRST_NAME,
ACTOR.LAST_NAME
from ACTOR
) as u
order by u.ACTOR_ID
offset 30 rows
fetch next 15 rows only
) as t
order by t.ACTOR_ID
这里是你如何调用这个工具的:
System.out.println(
paginate(
ctx,
ctx.select(ACTOR.ACTOR_ID, ACTOR.FIRST_NAME, ACTOR.LAST_NAME)
.from(ACTOR),
new Field[] { ACTOR.ACTOR_ID },
15,
30
).fetch()
);
请注意,你可以将任意的SQL片段插入该工具中,并对其进行分页。无论复杂程度如何(包括连接、其他窗口函数、分组、递归等等),jOOQ都会帮你搞定,现在会替你分页。
上述的结果是:
+--------+----------+---------+----------------+---------+----------+----+------------+
|ACTOR_ID|FIRST_NAME|LAST_NAME|actual_page_size|last_page|total_rows| row|current_page|
+--------+----------+---------+----------------+---------+----------+----+------------+
| 31|SISSY |SOBIESKI | 15|false | 200| 31| 3|
| 32|TIM |HACKMAN | 15|false | 200| 32| 3|
| 33|MILLA |PECK | 15|false | 200| 33| 3|
| 34|AUDREY |OLIVIER | 15|false | 200| 34| 3|
| 35|JUDY |DEAN | 15|false | 200| 35| 3|
| 36|BURT |DUKAKIS | 15|false | 200| 36| 3|
| 37|VAL |BOLGER | 15|false | 200| 37| 3|
| 38|TOM |MCKELLEN | 15|false | 200| 38| 3|
| 39|GOLDIE |BRODY | 15|false | 200| 39| 3|
| 40|JOHNNY |CAGE | 15|false | 200| 40| 3|
| 41|JODIE |DEGENERES| 15|false | 200| 41| 3|
| 42|TOM |MIRANDA | 15|false | 200| 42| 3|
| 43|KIRK |JOVOVICH | 15|false | 200| 43| 3|
| 44|NICK |STALLONE | 15|false | 200| 44| 3|
| 45|REESE |KILMER | 15|false | 200| 45| 3|
+--------+----------+---------+----------------+---------+----------+----+------------+
或者,在最后一页,偏移量为195
+--------+----------+---------+----------------+---------+----------+----+------------+
|ACTOR_ID|FIRST_NAME|LAST_NAME|actual_page_size|last_page|total_rows| row|current_page|
+--------+----------+---------+----------------+---------+----------+----+------------+
| 196|BELA |WALKEN | 5|true | 200| 196| 14|
| 197|REESE |WEST | 5|true | 200| 197| 14|
| 198|MARY |KEITEL | 5|true | 200| 198| 14|
| 199|JULIA |FAWCETT | 5|true | 200| 199| 14|
| 200|THORA |TEMPLE | 5|true | 200| 200| 14|
+--------+----------+---------+----------------+---------+----------+----+------------+
结论
jOOQ都是关于动态SQL的。几乎没有任何SQL功能是jOOQ不支持的。这包括窗口函数,例如,但也确保你的动态SQL能在大量的SQL方言上工作,而不考虑小的语法细节。
你可以建立自己的库,从其他SQL构件中构建可重用的SQL元素,正如本文所示,动态创建单查询OFFSET
分页元数据计算,而无需执行额外的数据库往返。