这就是SQL一直以来应该被使用的方式。
他们称之为"第三宣言"、ORDBMS或其他东西。遗憾的是,它从未真正起飞过。因为大多数供应商没有采用它。而那些采用的厂商在语法上也没有达成一致。
但这种情况即将改变。由于现在无处不在的SQL/JSON支持(jOOQ 3.14已经涵盖了这一点),我们现在可以模仿最强大的ORDBMS功能,你会想到处使用它。嵌套集合!
我们过去是如何做事的:使用连接
在这个例子中,我们将使用Sakila数据库。这是一个DVD租赁商店,有诸如ACTOR
、FILM
、CATEGORY
(电影的)和其他漂亮的关系性东西。让我们为这个需求写一个查询
给我所有的电影及其演员和他们的类别
传统上,我们会继续使用jOOQ来写:
ctx.select(
FILM.TITLE,
ACTOR.FIRST_NAME,
ACTOR.LAST_NAME,
CATEGORY.NAME
)
.from(ACTOR)
.join(FILM_ACTOR)
.on(ACTOR.ACTOR_ID.eq(FILM_ACTOR.ACTOR_ID))
.join(FILM)
.on(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
.join(FILM_CATEGORY)
.on(FILM.FILM_ID.eq(FILM_CATEGORY.FILM_ID))
.join(CATEGORY)
.on(FILM_CATEGORY.CATEGORY_ID.eq(CATEGORY.CATEGORY_ID))
.orderBy(1, 2, 3, 4)
.fetch();
结果呢?不是那么好。一个包含大量重复内容的去规范化的平面表:
+----------------+----------+---------+-----------+
|title |first_name|last_name|name |
+----------------+----------+---------+-----------+
|ACADEMY DINOSAUR|CHRISTIAN |GABLE |Documentary|
|ACADEMY DINOSAUR|JOHNNY |CAGE |Documentary|
|ACADEMY DINOSAUR|LUCILLE |TRACY |Documentary|
|ACADEMY DINOSAUR|MARY |KEITEL |Documentary|
|ACADEMY DINOSAUR|MENA |TEMPLE |Documentary|
|ACADEMY DINOSAUR|OPRAH |KILMER |Documentary|
|ACADEMY DINOSAUR|PENELOPE |GUINESS |Documentary|
|ACADEMY DINOSAUR|ROCK |DUKAKIS |Documentary|
|ACADEMY DINOSAUR|SANDRA |PECK |Documentary|
|ACADEMY DINOSAUR|WARREN |NOLTE |Documentary|
|ACE GOLDFINGER |BOB |FAWCETT |Horror |
|ACE GOLDFINGER |CHRIS |DEPP |Horror |
|ACE GOLDFINGER |MINNIE |ZELLWEGER|Horror |
...
如果我们不消费这个未经修改的结果(例如,在向用户显示表格数据时),我们就会去掉重复的东西,把它们重新塞回一些嵌套的数据结构中(如以供基于JSON的用户界面使用),并花费数小时来解开两个嵌套集合FILM
->ACTOR
和FILM
->CATEGORY
之间的笛卡尔产品(因为ACTOR
和CATEGORY
现在创建了一个笛卡尔产品,这不是我们想要的!)。
在最坏的情况下,我们甚至都没有注意到!这个例子的数据库每部电影只有一个类别,但它被设计为支持多个类别。
jOOQ可以帮助实现这种重复数据删除,但只要看看Stack Overflow上jOOQ的问题多到什么程度就知道了!你可能还是要写至少2个查询来分离嵌套的集合。
进入舞台:多数据集
标准的SQL<multiset value constructor>
操作符允许从一个相关的子查询中收集数据到一个嵌套的数据结构中,即MULTISET
。SQL中的所有东西都是a [MULTISET](https://en.wikipedia.org/wiki/Multiset)
,所以这个操作符并不太令人惊讶。但嵌套是它的闪光点。前面的查询现在可以在jOOQ中重新写成如下:
var result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors"),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("categories")
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
注意,这与本任务无关,但我使用了jOOQ的类型安全的隐式到一连接功能,这在语法上进一步帮助驯服了连接。一个品味问题。
如何阅读这个查询?很简单:
- 获取所有的电影
- 对于每个
FILM
,获得所有的演员作为一个嵌套的集合 - 对于每个
FILM
,将所有的类别作为一个嵌套的集合。
在这之后,你会更喜欢Java 10的var
关键字 :)因为result
是什么类型?它是这种类型的:
Result<Record3<
String, // FILM.TITLE
Result<Record2<String, String>>, // ACTOR.FIRST_NAME, ACTOR.LAST_NAME
Result<Record1<String>> // CATEGORY.NAME
>>
这可真不简单。如果你想一想,也不算太复杂。有一个有3列的结果:
TITLE
- 第一个嵌套的结果,有2个字符串列:
ACTOR.FIRST_NAME
和ACTOR.LAST_NAME
- 第二个嵌套的结果,有1个字符串列。
CATEGORY.NAME
使用var
或其他类型推理机制,你不需要表示这个类型。甚至更好(敬请期待)。我们将类型安全地将结构类型映射到我们的名义DTO类型层次中,只需增加几行代码。我将在后面解释这个问题。
结果是什么样子的?
在上述Result
类型上调用toString()
,结果是这样的:
+---------------------------+--------------------------------------------------+---------------+
|title |actors |categories |
+---------------------------+--------------------------------------------------+---------------+
|ACADEMY DINOSAUR |[(PENELOPE, GUINESS), (CHRISTIAN, GABLE), (LUCI...|[(Documentary)]|
|ACE GOLDFINGER |[(BOB, FAWCETT), (MINNIE, ZELLWEGER), (SEAN, GU...|[(Horror)] |
|ADAPTATION HOLES |[(NICK, WAHLBERG), (BOB, FAWCETT), (CAMERON, ST...|[(Documentary)]|
|AFFAIR PREJUDICE |[(JODIE, DEGENERES), (SCARLETT, DAMON), (KENNET...|[(Horror)] |
|AFRICAN EGG |[(GARY, PHOENIX), (DUSTIN, TAUTOU), (MATTHEW, L...|[(Family)] |
|AGENT TRUMAN |[(KIRSTEN, PALTROW), (SANDRA, KILMER), (JAYNE, ...|[(Foreign)] |
...
请注意,我们又回到了每个FILM.TITLE
条目只有一次的状态(没有重复),而且每一行都嵌套着子查询结果。没有发生任何反规范化的情况!
当用适当的格式化选项调用result.formatJSON()
,我们会得到这样的表述:
[
{
"title": "ACADEMY DINOSAUR",
"actors": [
{
"first_name": "PENELOPE",
"last_name": "GUINESS"
},
{
"first_name": "CHRISTIAN",
"last_name": "GABLE"
},
{
"first_name": "LUCILLE",
"last_name": "TRACY"
},
{
"first_name": "SANDRA",
"last_name": "PECK"
},
...
],
"categories": [
{ "name": "Documentary" }
]
},
{
"title": "ACE GOLDFINGER",
"actors": [
{
"first_name": "BOB",
"last_name": "FAWCETT"
},
...
调用result.formatXML()
,会产生这样的结果:
<result>
<record>
<title>ACADEMY DINOSAUR</title>
<actors>
<result>
<record>
<first_name>PENELOPE</first_name>
<last_name>GUINESS</last_name>
</record>
<record>
<first_name>CHRISTIAN</first_name>
<last_name>GABLE</last_name>
</record>
<record>
<first_name>LUCILLE</first_name>
<last_name>TRACY</last_name>
</record>
<record>
<first_name>SANDRA</first_name>
<last_name>PECK</last_name>
</record>
...
</result>
</actors>
<categories>
<result>
<record>
<name>Documentary</name>
</record>
</result>
</categories>
</record>
<record>
<title>ACE GOLDFINGER</title>
<actors>
<result>
<record>
<first_name>BOB</first_name>
<last_name>FAWCETT</last_name>
</record>
...
你明白了吧!
生成的SQL是什么?
只要打开jOOQ的DEBUG
日志,观察像这样的查询(在PostgreSQL中):
select
film.title,
(
select coalesce(
jsonb_agg(jsonb_build_object(
'first_name', t.first_name,
'last_name', t.last_name
)),
jsonb_build_array()
)
from (
select
alias_78509018.first_name,
alias_78509018.last_name
from (
film_actor
join actor as alias_78509018
on film_actor.actor_id = alias_78509018.actor_id
)
where film_actor.film_id = film.film_id
) as t
) as actors,
(
select coalesce(
jsonb_agg(jsonb_build_object('name', t.name)),
jsonb_build_array()
)
from (
select alias_130639425.name
from (
film_category
join category as alias_130639425
on film_category.category_id = alias_130639425.category_id
)
where film_category.film_id = film.film_id
) as t
) as categories
from film
order by film.title
Db2、MySQL、Oracle、SQL Server的版本看起来都差不多。就在你的Sakila数据库安装上试试吧。它的运行速度也很快。
将结果映射到DTOs
现在,我承诺要摆脱那个带有所有泛型的冗长结构类型。看看这个吧!
我们曾经称它们为POJOs(Plain Old Java Objects)。然后是DTOs(数据传输对象)。现在是记录。是的,让我们在这里尝试一下Java 16的记录。 (注意,这些例子中不需要记录。任何具有适当构造函数的POJO都可以)。
如果result
是这种类型,那不是很好吗?
record Actor(String firstName, String lastName) {}
record Film(
String title,
List<Actor> actors,
List<String> categories
) {}
List<Film> result = ...
结构类型对于jOOQ和它的类型安全查询系统来说是必不可少的,但是在你的代码中,你可能不希望一直有那些合并冲突的外观泛型,如果你的数据需要从一个方法中返回,甚至var
也帮不了你。
所以,让我们一步一步地把我们的jOOQ查询转变为能产生List<Film>
,。我们从原始查询开始,不做任何改动:
Result<Record3<
String,
Result<Record2<String, String>>,
Result<Record1<String>>
>> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors"),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("categories")
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
现在,我们要对第一个MULTISET
表达式中的Actor
进行映射。这可以通过以下方式完成,使用新的 [Field.convertFrom()](https://www.jooq.org/javadoc/dev/org.jooq/org/jooq/Field.html#convertFrom(java.util.function.Function))
方便的方法,它允许把一个Field<T>
变成任何只读的Field<U>
,以便临时使用。一个简单的例子是这样的:
record Title(String title) {}
// Use this field in any query
Field<Title> title = FILM.TITLE.convertFrom(Title::new);
这只是一个简单的新方法,可以将一个只读的 [Converter](https://www.jooq.org/javadoc/latest/org.jooq/org/jooq/Converter.html)
到一个Field
,而不是用代码生成器来做。
应用于原始查询:
Result<Record3<
String,
List<Actor>, // A bit nicer already
Result<Record1<String>>
>> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
// Magic here: vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
).as("actors").convertFrom(r -> r.map(mapping(Actor::new))),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("categories")
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
我们在这里做什么?
- 方法
convertFrom()
需要一个lambdaResult<Record2<String, String>> -> Actor
。 Result
类型是通常的jOOQResult
,它有一个Result.map(RecordMapper<R, E>)
方法。mapping()
方法是新的[Records.mapping()](https://www.jooq.org/javadoc/dev/org.jooq/org/jooq/Records.html#mapping(org.jooq.Function2))
,它并没有做什么,只是把一个Function2<String, String, Actor>
类型的构造器引用变成一个[RecordMapper](https://www.jooq.org/javadoc/latest/org.jooq/org/jooq/RecordMapper.html)
,然后可以用来把一个Result<Record2<String, String>>
变成一个List<Actor>
。
而且它进行类型检查!自己试试吧。如果你向多数据集添加一个列,你会得到一个编译错误。如果你从Actor
记录中添加/删除一个属性,你会得到一个编译错误。这里没有反射,只是将jOOQ的结果/记录声明性地映射到自定义List<UserType>
。如果你喜欢使用jOOQ无处不在的into()
方法的 "老 "反射方法,你也可以这样做。
Result<Record3<
String,
List<Actor>, // A bit nicer already
Result<Record1<String>>
>> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
// Magic here: vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
).as("actors").convertFrom(r -> r.into(Actor.class))),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("categories")
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
结果仍然是类型检查,但从Result<Record2<String, String>>
到List<Actor>
的转换不再是这样,它使用反射。
让我们继续。让我们删除笨拙的类别Result<Record1<String>>
。我们可以再加一条记录,但在这种情况下,一个List<String>
就足够了:
Result<Record3<
String,
List<Actor>,
List<String> // Begone, jOOQ structural type!
>> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors").convertFrom(r -> r.map(mapping(Actor::new))),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
// Magic. vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
).as("categories").convertFrom(r -> r.map(Record1::value1))
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
最后,最外层的Result<Record3<...>>
到List<Film>
的转换:
List<Film> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors").convertFrom(r -> r.map(mapping(Actor::new))),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("categories").convertFrom(r -> r.map(Record1::value1))
)
.from(FILM)
.orderBy(FILM.TITLE)
// vvvvvvvvvvvvvvvvvv grande finale
.fetch(mapping(Film::new));
这一次,我们不需要 [Field.convertFrom()](https://www.jooq.org/javadoc/dev/org.jooq/org/jooq/Field.html#convertFrom(java.util.function.Function))
方法。只要使用 [Records.mapping()](https://www.jooq.org/javadoc/dev/org.jooq/org/jooq/Records.html#mapping(org.jooq.Function2))
辅佐就足够了。
一个更复杂的例子
前面的例子显示了两个独立集合的嵌套,这在经典的基于JOIN
的SQL或ORM中是相当困难的。那么一个更复杂的例子呢,我们把东西嵌套到两层,其中一层是一个聚合,甚至?我们的要求是:
给我所有的电影,电影中的演员,电影的分类,租过电影的客户,以及每个客户对该电影的所有付款。
我甚至不会展示一个基于JOIN
的方法。让我们直接进入MULTISET
和同样新的、合成的MULTISET_AGG
聚合函数。下面是如何用jOOQ来做这个。现在,看看那个漂亮的结果类型:
Result<Record4<
String, // FILM.TITLE
Result<Record2<
String, // ACTOR.FIRST_NAME
String // ACTOR.LAST_NAME
>>, // "actors"
Result<Record1<String>>, // CATEGORY.NAME
Result<Record4<
String, // CUSTOMER.FIRST_NAME
String, // CUSTOMER.LAST_NAME
Result<Record2<
LocalDateTime, // PAYMENT.PAYMENT_DATE
BigDecimal // PAYMENT.AMOUNT
>>,
BigDecimal // "total"
>> // "customers"
>> result =
dsl.select(
// Get the films
FILM.TITLE,
// ... and all actors that played in the film
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME
)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors"),
// ... and all categories that categorise the film
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("categories"),
// ... and all customers who rented the film, as well
// as their payments
multiset(
select(
PAYMENT.rental().customer().FIRST_NAME,
PAYMENT.rental().customer().LAST_NAME,
multisetAgg(
PAYMENT.PAYMENT_DATE,
PAYMENT.AMOUNT
).as("payments"),
sum(PAYMENT.AMOUNT).as("total"))
.from(PAYMENT)
.where(PAYMENT
.rental().inventory().FILM_ID.eq(FILM.FILM_ID))
.groupBy(
PAYMENT.rental().customer().CUSTOMER_ID,
PAYMENT.rental().customer().FIRST_NAME,
PAYMENT.rental().customer().LAST_NAME)
).as("customers")
)
.from(FILM)
.where(FILM.TITLE.like("A%"))
.orderBy(FILM.TITLE)
.limit(5)
.fetch();
你将会使用var
,当然,而不是表示这个疯狂的类型,但为了这个例子,我想明确表示这个类型。
再次注意隐式连接在这里是多么的有用,因为我们想汇总每个客户的所有付款,我们可以直接从PAYMENT
,并通过付款的PAYMENT.rental().customer()
进行分组,以及通过PAYMENT.rental().inventory().FILM_ID
进行子查询,不需要任何额外的努力。
执行的SQL看起来像这样,你可以看到生成的隐式连接(在你的PostgreSQL Sakila数据库上运行它!):
select
film.title,
(
select coalesce(
jsonb_agg(jsonb_build_object(
'first_name', t.first_name,
'last_name', t.last_name
)),
jsonb_build_array()
)
from (
select alias_78509018.first_name, alias_78509018.last_name
from (
film_actor
join actor as alias_78509018
on film_actor.actor_id = alias_78509018.actor_id
)
where film_actor.film_id = film.film_id
) as t
) as actors,
(
select coalesce(
jsonb_agg(jsonb_build_object('name', t.name)),
jsonb_build_array()
)
from (
select alias_130639425.name
from (
film_category
join category as alias_130639425
on film_category.category_id =
alias_130639425.category_id
)
where film_category.film_id = film.film_id
) as t
) as categories,
(
select coalesce(
jsonb_agg(jsonb_build_object(
'first_name', t.first_name,
'last_name', t.last_name,
'payments', t.payments,
'total', t.total
)),
jsonb_build_array()
)
from (
select
alias_63965917.first_name,
alias_63965917.last_name,
jsonb_agg(jsonb_build_object(
'payment_date', payment.payment_date,
'amount', payment.amount
)) as payments,
sum(payment.amount) as total
from (
payment
join (
rental as alias_102068213
join customer as alias_63965917
on alias_102068213.customer_id =
alias_63965917.customer_id
join inventory as alias_116526225
on alias_102068213.inventory_id =
alias_116526225.inventory_id
)
on payment.rental_id = alias_102068213.rental_id
)
where alias_116526225.film_id = film.film_id
group by
alias_63965917.customer_id,
alias_63965917.first_name,
alias_63965917.last_name
) as t
) as customers
from film
where film.title like 'A%'
order by film.title
fetch next 5 rows only
结果,在JSON中,现在看起来是这样的:
[
{
"title": "ACADEMY DINOSAUR",
"actors": [
{
"first_name": "PENELOPE",
"last_name": "GUINESS"
},
{
"first_name": "CHRISTIAN",
"last_name": "GABLE"
},
{
"first_name": "LUCILLE",
"last_name": "TRACY"
},
...
],
"categories": [{ "name": "Documentary" }],
"customers": [
{
"first_name": "SUSAN",
"last_name": "WILSON",
"payments": [
{
"payment_date": "2005-07-31T22:08:29",
"amount": 0.99
}
],
"total": 0.99
},
{
"first_name": "REBECCA",
"last_name": "SCOTT",
"payments": [
{
"payment_date": "2005-08-18T18:36:16",
"amount": 0.99
}
],
"total": 0.99
},
...
就这样了。以任意的方式嵌套集合是完全不费吹灰之力和直观的。没有N+1,没有重复数据删除。只要在你的客户中以你需要的形式声明结果就可以了。
除了让你的RDBMS完成规划和运行这种查询的重任,并让jOOQ完成映射之外,没有其他方法可以如此轻松地完成这种复杂的工作。
总结
我们一直都有这样的功能。只是我们从未使用过,或者说使用得不够。为什么?因为客户端的API没有让它变得足够方便。因为RDBMS在语法上没有足够的共识。
jOOQ在其DSL API中使用了标准的SQLMULTISET
语法,用合成的MULTISET_AGG
聚合函数来增强它,所有的RDBMS都应该 实现它(去Informix,Oracle)。我们可以再等40年,等其他RDBMS实现这个,或者我们今天就使用jOOQ。
而且,我不能不强调这一点:
- 这都是类型安全的
- 没有反射
- 嵌套是在数据库中使用SQL完成的(目前是通过SQL/XML或SQL/JSON)。
- ......因此,执行计划者可以优化你的整个查询
- ...在数据库中没有额外的列或额外的查询或其他额外的工作被执行
这适用于所有支持SQL/XML或SQL/JSON(或两者)的方言,包括主要的流行方言:
- MySQL
- Oracle
- PostgreSQL
- SQLServer
而且它是在jOOQ的常规许可条款下提供的。所以,快乐地嵌套集合。
补遗:直接使用SQL/XML或SQL/JSON
你可能很想到处使用这个。而且你理所当然地这样做。但要注意这一点,如果你的SQL客户端是直接消费XML或JSON,就没有必要使用MULTISET
。使用jOOQ在jOOQ 3.14中引入的本地SQL/XML或SQL/JSON支持。这样,你就不会从JSON到jOOQ的结果转换为JSON,而是直接将JSON(或XML)流向你的前端。