如何使用JDK采集器去除父/子嵌套集合的重复性

449 阅读5分钟

在传统的SQL中(即在jOOQ的MULTISET操作符之前),嵌套的集合是使用普通的(外)连接来获取的。这种查询的一个例子是针对sakila数据库运行的查询,以获取演员和他们的电影。使用jOOQ:

Result<?> result =
ctx.select(
        ACTOR.ACTOR_ID,
        ACTOR.FIRST_NAME,
        ACTOR.LAST_NAME,
        FILM.FILM_ID,
        FILM.TITLE)
   .from(ACTOR)
   .leftJoin(FILM_ACTOR).on(ACTOR.ACTOR_ID.eq(FILM_ACTOR.ACTOR_ID))
   .leftJoin(FILM).on(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
   .orderBy(
       ACTOR.ACTOR_ID,
       FILM.FILM_ID)
   .fetch();

jOOQ的调试日志的结果看起来是这样的:

+--------+----------+---------+-------+---------------------+
|actor_id|first_name|last_name|film_id|title                |
+--------+----------+---------+-------+---------------------+
|       1|PENELOPE  |GUINESS  |      1|ACADEMY DINOSAUR     |
|       1|PENELOPE  |GUINESS  |     23|ANACONDA CONFESSIONS |
|       1|PENELOPE  |GUINESS  |     25|ANGELS LIFE          |
|       1|PENELOPE  |GUINESS  |    106|BULWORTH COMMANDMENTS|
|       1|PENELOPE  |GUINESS  |    140|CHEAPER CLYDE        |
+--------+----------+---------+-------+---------------------+

正如SQL连接操作所预期的那样,这将使数据变的正常化,导致每个演员的重复条目,或者如果你以不同的方式排序,例如通过FILM_ID ,那么你也会看到每部影片的重复条目:

+--------+----------+---------+-------+----------------+
|actor_id|first_name|last_name|film_id|title           |
+--------+----------+---------+-------+----------------+
|       1|PENELOPE  |GUINESS  |      1|ACADEMY DINOSAUR|
|      10|CHRISTIAN |GABLE    |      1|ACADEMY DINOSAUR|
|      20|LUCILLE   |TRACY    |      1|ACADEMY DINOSAUR|
|      30|SANDRA    |PECK     |      1|ACADEMY DINOSAUR|
|      40|JOHNNY    |CAGE     |      1|ACADEMY DINOSAUR|
+--------+----------+---------+-------+----------------+

这就是连接操作的工作方式。它创建了一个笛卡尔乘积,然后通过主键/外键匹配来过滤,JPA用户知道的名字是MultipleBagFetchException

用fetchGroups()对集合进行去重和嵌套

我们通常想要的是某种嵌套的数据结构,比如说, 演员来获取电影。在jOOQ中,一个简单的工具是直接使用fetchGroups()

Map<ActorRecord, Result<FilmRecord>> result =
ctx.select(
        ACTOR.ACTOR_ID,
        ACTOR.FIRST_NAME,
        ACTOR.LAST_NAME,
        FILM.FILM_ID,
        FILM.TITLE)
   .from(ACTOR)
   .leftJoin(FILM_ACTOR).on(ACTOR.ACTOR_ID.eq(FILM_ACTOR.ACTOR_ID))
   .leftJoin(FILM).on(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
   .orderBy(
       ACTOR.ACTOR_ID,
       FILM.FILM_ID)
   .fetchGroups(ACTOR, FILM);

这是很方便的,它可以保持秩序,但它有一个缺陷。它不够 "聪明",不能记住LEFT JOIN 的语义并做正确的事情。™,也就是在演员没有任何电影的情况下产生一个FilmRecord 的空列表。如果是这样的话,那么在SQL的结果集中就有一个NULL

+--------+----------+---------+-------+---------------------+
|actor_id|first_name|last_name|film_id|title                |
+--------+----------+---------+-------+---------------------+
|       1|PENELOPE  |GUINESS  |      1|ACADEMY DINOSAUR     |
|       1|PENELOPE  |GUINESS  |     23|ANACONDA CONFESSIONS |
|       1|PENELOPE  |GUINESS  |     25|ANGELS LIFE          |
|       1|PENELOPE  |GUINESS  |    106|BULWORTH COMMANDMENTS|
|       1|PENELOPE  |GUINESS  |    140|CHEAPER CLYDE        |
|     ...|...       |...      |    ...|...                  |

所以,我们不会得到一个空的列表,而是一个包含空的FilmRecord ,就像你在GROUP BY actor和COUNT(*) the number of films:

var r =
ctx.select(
        ACTOR.ACTOR_ID,
        ACTOR.FIRST_NAME,
        ACTOR.LAST_NAME,
        count(),
        count(FILM.FILM_ID))
   .from(ACTOR)
   .leftJoin(FILM_ACTOR).on(ACTOR.ACTOR_ID.eq(FILM_ACTOR.ACTOR_ID))
   .leftJoin(FILM).on(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
   .groupBy(
        ACTOR.ACTOR_ID,
        ACTOR.FIRST_NAME,
        ACTOR.LAST_NAME)
   .orderBy(
       ACTOR.ACTOR_ID)
   .fetch();

这个查询的结果可能看起来像这样:

+--------+----------+------------+-----+-----+
|actor_id|first_name|last_name   |count|count|
+--------+----------+------------+-----+-----+
|       1|PENELOPE  |GUINESS     |   19|   19|
|       2|NICK      |WAHLBERG    |   25|   25|
|       3|ED        |CHASE       |   22|   22|
|       4|JENNIFER  |DAVIS       |   22|   22|
|       5|JOHNNY    |LOLLOBRIGIDA|   29|   29|
|     ...|...       |...         |  ...|  ...|

观察一下,只有当我们把可空的FILM.FILM_ID 列作为参数时,才能实现所需的计数值0。那么,暴露这种行为的等价的fetchGroups() 调用会是什么?

用JDK收集器重复使用和嵌套集合

JDK的一个非常被低估的功能是Collectors 。虽然它们是专门为与Stream API一起使用而引入的,但原则上它们可以与任何类型的Iterable 一起使用,而且我仍然希望未来的JDK能够提供Iterable.collect() 等。

使用jOOQ,你可以通过调用以下语句来收集任何查询的结果 [ResultQuery.collect()](https://www.jooq.org/javadoc/latest/org.jooq/org/jooq/ResultQuery.html#collect(java.util.stream.Collector)).为了翻译上述fetchGroups() 的例子,我们可以这样写,产生的结果几乎一样:

Map<ActorRecord, List<FilmRecord>> result =
ctx.select(
        ACTOR.ACTOR_ID,
        ACTOR.FIRST_NAME,
        ACTOR.LAST_NAME,
        FILM.FILM_ID,
        FILM.TITLE)
   .from(ACTOR)
   .leftJoin(FILM_ACTOR).on(ACTOR.ACTOR_ID.eq(FILM_ACTOR.ACTOR_ID))
   .leftJoin(FILM).on(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
   .orderBy(
       ACTOR.ACTOR_ID,
       FILM.FILM_ID)
   .collect(groupingBy(
       r -> r.into(ACTOR), filtering(
           r -> r.get(FILM.FILM_ID) != null, mapping(
               r -> r.into(FILM), toList()
           )
       )
   ));

上面的collect()调用嵌套了这些操作:

  • 它按演员分组(就像fetchGroups() )。
  • 它通过那些ID 不是NULL 的电影来过滤组的内容(这不能通过fetchGroups() 来实现)。
  • 它将组的内容映射为只包含FILM ,而不是整个投影。

因此,很明显,它更加冗长,但也更加强大,并且容易引入自定义的客户端聚合行为,如果你不能将聚合逻辑移到你的SQL语句中的话。

在这篇文章中,有更多关于强大的收集器的内容:

鲜为人知的jOOλ功能:有用的收集器

在SQL中直接嵌套集合

本博客的任何一篇文章,如果不插入令人敬畏的MULTISET ,就不能算是完整的嵌套集合。毕竟,上述重复数据删除算法只有在你加入一个单一的父子关系路径时才真正有效,而当有很多重复的数据集时,它的效率就相当低。

假设有这些辅助的数据类型:

record Film(String title) {}
record Actor(String firstName, String lastName) {}
record Category(String name) {}

你可以写一个这样的查询:

// We're importing the new Records::mapping method for convenience
import static org.jooq.Records.mapping;

Result<Record3<Film, List<Actor>, List<Category>>> result = ctx
    .select(
        FILM.TITLE.convertFrom(Film::new),
        multiset(
            select(
                FILM_ACTOR.actor().FIRST_NAME,
                FILM_ACTOR.actor().LAST_NAME
            )
            .from(FILM_ACTOR)
            .where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
        ).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))
        ).convertFrom(r -> r.map(mapping(Category::new)))
    )
    .from(FILM)
    .orderBy(FILM.TITLE)
    .fetch();

注意,我们现在得到的结果集包含了:

  • 所有的电影
  • 每部电影的演员作为一个嵌套集合
  • 每部电影的类别作为另一个嵌套集合

这个例子使用了隐式连接,以避免在MULTISET 表达式中使用一些更冗长的连接语法,但这与这个例子并不严格相关。

其结果是这样的:

+----------------------------+--------------------------------------------------+----------------------------+
|title                       |multiset                                          |multiset                    |
+----------------------------+--------------------------------------------------+----------------------------+
|Film[title=ACADEMY DINOSAUR]|[Actor[firstName=PENELOPE, lastName=GUINESS], A...|[Category[name=Documentary]]|
|Film[title=ACE GOLDFINGER]  |[Actor[firstName=BOB, lastName=FAWCETT], Actor[...|[Category[name=Horror]]     |
|Film[title=ADAPTATION HOLES]|[Actor[firstName=NICK, lastName=WAHLBERG], Acto...|[Category[name=Documentary]]|
|Film[title=AFFAIR PREJUDICE]|[Actor[firstName=JODIE, lastName=DEGENERES], Ac...|[Category[name=Horror]]     |
|Film[title=AFRICAN EGG]     |[Actor[firstName=GARY, lastName=PHOENIX], Actor...|[Category[name=Family]]     |
+----------------------------+--------------------------------------------------+----------------------------+

结论

有很多方法可以通向罗马。经典的基于SQL的嵌套集合的方法在客户端使用某种重复数据删除技术。在jOOQ中,你总是可以用fetchGroups() ,而且最近还可以直接用collect()collect() 总是通过中间的stream() 调用)。

然而,对于真正强大的集合嵌套,我们的建议是将你的逻辑直接移到SQL中,使用本地数组功能,或使用 [MULTISET](https://blog.jooq.org/jooq-3-15s-new-multiset-operator-will-change-how-you-think-about-sql/)