JOOQ-大师课-全-
JOOQ 大师课(全)
原文:
zh.annas-archive.org/md5/a6f275dfdbcc66224515393b57acdb96译者:飞龙
前言
过去的十年不断改变着我们的思维和编写应用程序的方式,包括持久化层,它必须面对新的挑战,如工作在微服务架构和云环境中。灵活性、多功能性、方言无关性、坚如磐石的 SQL 支持、学习曲线小、高性能只是使 jOOQ 成为现代应用程序最具吸引力的持久化技术的一些属性。
作为现代技术栈的一部分,jOOQ 是尊重成熟、稳健和良好文档化的技术所有标准的新的持久化趋势。本书详细介绍了 jOOQ,因此它为您成为 jOOQ 高级用户和准备应对持久化层未来的升级版自己做好准备。不要将 jOOQ 视为仅仅是另一项技术;将其视为您心态的一部分,您直接利用 SQL 而不是抽象 SQL 的简单途径,以及您在组织中正确做事的方法。
本书面向的对象
本书面向编写通过 SQL 与数据库交互的应用程序的 Java 开发者。假设您没有使用 jOOQ 的先验经验。
本书涵盖的内容
第一章,启动 jOOQ 和 Spring Boot,展示了如何在 Java/Kotlin 下使用 Maven/Gradle 创建涉及 jOOQ 和 Spring Boot 的启动应用程序。
第二章,定制 jOOQ 的参与级别,涵盖了使用 jOOQ 作为类型安全的查询构建器和执行器所需的配置(声明性和程序性)。此外,我们设置 jOOQ 代表我们生成 POJOs 和 DAOs。我们使用 Java/Kotlin 在 Maven/Gradle 下。
第三章,jOOQ 核心概念,讨论了 jOOQ 核心概念,如流畅 API、SQL 语法正确性、模拟缺失的语法/逻辑、jOOQ 结果集、jOOQ 记录、类型安全、CRUD 绑定和内联参数。
第四章,构建 DAO 层(演变生成的 DAO 层),展示了以多种方式/模板实现 DAO 层的方法。我们探讨了如何演变由 jOOQ 生成的 DAO 层。
第五章,处理不同类型的 SELECT、INSERT、UPDATE、DELETE 和 MERGE 语句,涵盖了不同类型的SELECT、INSERT、UPDATE、DELETE和MERGE查询。例如,我们涵盖了嵌套SELECT、INSERT...DEFAULT VALUES、INSERT...SET查询等。
第六章,处理不同类型的 JOIN 语句,处理不同类型的JOIN。jOOQ 在标准和非标准JOIN方面表现出色。我们涵盖了INNER、LEFT、RIGHT、…、CROSS、NATURAL和LATERAL JOIN。
第七章,类型、转换器和绑定,涵盖了自定义数据类型、转换和绑定。
第八章,获取和映射,作为最全面的章节之一,涵盖了广泛的 jOOQ 获取和映射技术,包括 JSON/SQL、XML/SQL 和多集功能。
第九章,CRUD、事务和锁定,在 Spring/jOOQ 事务和乐观/悲观锁定旁边涵盖了 jOOQ 的 CRUD 支持。
第十章,导出、批处理、批量加载和加载,在 jOOQ 中涵盖了批量、批量加载和将文件加载到数据库中的操作。我们将进行单线程和多线程的批处理。
第十一章,jOOQ 键,从 jOOQ 的角度探讨了不同类型的标识符(自动生成标识符、自然标识符和组合标识符)。
第十二章,分页和动态查询,涵盖了分页和构建动态查询。主要来说,所有 jOOQ 查询都是动态的,但在这章中,我们将强调这一点,并且我们将通过粘合和重用不同的 jOOQ 工具来编写几个过滤器。
第十三章,利用 SQL 函数,在 jOOQ 的上下文中涵盖了窗口函数(可能是最强大的 SQL 功能)。
第十四章,派生表、CTE 和视图,在 jOOQ 的上下文中涵盖了派生表和递归的公共表表达式(CTE)。
第十五章,调用和创建存储函数和过程,在 jOOQ 的上下文中涵盖了存储过程和函数。这是 jOOQ 最强大和最受欢迎的功能之一。
第十六章,处理别名和 SQL 模板,涵盖了别名和 SQL 模板。正如你将看到的,这一章包含了一组必备的知识,这将帮助你避免常见的相关陷阱。
第十七章,jOOQ 中的多租户,涵盖了多租户/分区方面的不同方面。
第十八章,jOOQ SPI(提供者和监听器),涵盖了 jOOQ 提供者和监听器。使用这些类型的工具,我们可以干扰 jOOQ 的默认行为。
第十九章,日志记录和测试,涵盖了 jOOQ 的日志记录和测试。
为了充分利用这本书
为了充分利用这本书,你需要了解 Java 语言,并且熟悉以下数据库技术之一:

请参考此链接以获取额外的安装说明和设置所需的信息:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/db。
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/jOOQ-Masterclass。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供下载,请访问github.com/PacktPublishing/。查看它们!
下载彩色图像
我们还提供了一个包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/a1q9L。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“例如,下一个代码片段依赖于fetchInto()功能。”
代码块设置如下:
// 'query' is the ResultQuery object
List<Office> result = query.fetchInto(Office.class);
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
public List<Office> findOfficesInTerritory(
String territory) {
List<Office> result = ctx.selectFrom(table("office"))
.where(field("territory").eq(territory))
.fetchInto(Office.class);
return result;
}
任何命令行输入或输出都应如下所示:
<result>
<record>
<value field="product_line">Vintage Cars</value>
<value field="product_id">80</value>
<value field="product_name">1936 Mercedes Benz ...</value>
</record>
...
</result>
小贴士或重要注意事项
看起来是这样的。
联系我们
欢迎读者反馈
一般反馈:如果您对本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上遇到我们作品的任何非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
分享您的想法
一旦您阅读了jOOQ Masterclass,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
第一部分:jOOQ 作为查询构建器、SQL 执行器和代码生成器
在本部分结束时,您将了解如何在不同启动应用中利用标题中提到的上述三个术语。您将看到如何将 jOOQ 用作伴侣或作为您当前持久化技术(很可能是 ORM)的完全替代品。
本部分包含以下章节:
-
第一章, 启动 jOOQ 和 Spring Boot
-
第二章, 自定义 jOOQ 的参与级别
第一章:启动 jOOQ 和 Spring Boot
本章是使用 jOOQ(开源和免费试用商业版)在 Spring Boot 应用程序中开始工作的实用指南。为了方便,让我们假设我们有一个 Spring Boot 占位符应用程序,并计划通过 jOOQ 实现持久层。
本章的目标是强调在 Spring Boot 应用程序中通过 jOOQ 生成和执行 SQL 查询的环境设置几乎可以立即完成。除此之外,这也是体验 jOOQ DSL 流畅 API 并形成第一印象的好机会。
本章的主题包括以下内容:
-
立即启动 jOOQ 和 Spring Boot
-
使用 jOOQ 查询 DSL API 生成有效的 SQL 语句
-
执行生成的 SQL 并将结果集映射到 POJO
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter01。
立即启动 jOOQ 和 Spring Boot
Spring Boot 提供了对 jOOQ 的支持,这一点在 Spring Boot 官方文档的使用 jOOQ部分中有所介绍。内置对 jOOQ 的支持使我们的任务变得更容易,因为 Spring Boot 能够处理涉及有用默认配置和设置的各种方面。
考虑有一个针对 MySQL 和 Oracle 运行的 Spring Boot 占位符应用程序,让我们尝试将 jOOQ 添加到这个环境中。目标是使用 jOOQ 作为 SQL 构建器来构建有效的 SQL 语句,并将其作为 SQL 执行器将结果集映射到 POJO。
添加 jOOQ 开源版
将 jOOQ 开源版添加到 Spring Boot 应用程序中相当直接。
通过 Maven 添加 jOOQ 开源版
从 Maven 的角度来看,将 jOOQ 开源版添加到 Spring Boot 应用程序是从pom.xml文件开始的。jOOQ 开源版依赖项可在 Maven Central(mvnrepository.com/artifact/org.jooq/jooq)找到,可以添加如下:
<dependency>
<groupId>org.jooq</groupId>
<artifactId>jooq</artifactId>
<version>...</version> <!-- optional -->
</dependency>
或者,如果您更喜欢 Spring Boot 启动器,那么请依赖这个:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jooq</artifactId>
</dependency>
如果您是 Spring Initializr(start.spring.io/)的粉丝,那么只需从相应的依赖项列表中选择 jOOQ 依赖项即可。
就这些了!请注意,<version>是可选的。如果省略<version>,则 Spring Boot 将正确选择与应用程序使用的 Spring Boot 版本兼容的 jOOQ 版本。不过,无论何时您想尝试不同的 jOOQ 版本,都可以简单地显式添加<version>。此时,jOOQ 开源版已准备好用于开始开发应用程序的持久层。
通过 Gradle 添加 jOOQ 开源版
从 Gradle 的角度来看,将 jOOQ 开源版添加到 Spring Boot 应用程序中可以通过一个名为 gradle-jooq-plugin 的插件来实现 (github.com/etiennestuder/gradle-jooq-plugin/)。这可以添加到你的 build.gradle 文件中,如下所示:
plugins {
id 'nu.studer.jooq' version ...
}
当然,如果你依赖 Spring Initializr (start.spring.io/),那么只需选择一个 Gradle 项目,从相应的依赖列表中添加 jOOQ 依赖项,一旦项目生成,就添加 gradle-jooq-plugin 插件。正如你将在下一章中看到的,使用 gradle-jooq-plugin 配置 jOOQ 代码生成器非常方便。
添加 jOOQ 免费试用版(商业版)
将 jOOQ 免费试用版(商业版)添加到 Spring Boot 项目中(总的来说,在任意其他类型的项目中)需要几个初步步骤。主要因为这些步骤是必要的,因为 jOOQ 免费试用版的商业发行版不在 Maven Central 上,所以你必须手动从 jOOQ 下载页面 (www.jooq.org/download/) 下载你需要的版本。例如,你可以选择最受欢迎的版本,即 jOOQ 专业发行版,它被打包成一个 ZIP 归档。一旦解压,你可以通过 maven-install 命令本地安装它。你可以在附带代码中的简短电影中找到这些步骤的示例(Install_jOOQ_Trial.mp4)。
对于 Maven 应用程序,我们使用标识为 org.jooq.trial(针对 Java 17)或 org.jooq.trial-java-{version} 的 jOOQ 免费试用版。当本书编写时,version 占位符可以是 8 或 11,但请不要犹豫去检查最新的更新。我们更倾向于前者,因此,在 pom.xml 文件中,我们有以下内容:
<dependency>
<groupId>org.jooq.trial-java-8</groupId>
<artifactId>jooq</artifactId>
<version>...</version>
</dependency>
对于 Java/Gradle,你可以像以下示例那样通过 gradle-jooq-plugin 来实现:
jooq {
version = '...'
edition = nu.studer.gradle.jooq.JooqEdition.TRIAL_JAVA_8
}
对于 Kotlin/Gradle,你可以这样做:
jooq {
version.set(...)
edition.set(nu.studer.gradle.jooq.JooqEdition.TRIAL_JAVA_8)
}
在本书中,我们将使用 jOOQ 开源版在涉及 MySQL 和 PostgreSQL 的应用程序中,以及在涉及 SQL Server 和 Oracle 的应用程序中使用 jOOQ 免费试用版。这两个数据库供应商不支持 jOOQ 开源版。
如果你感兴趣在 Quarkus 项目中添加 jOOQ,那么可以考虑这个资源:github.com/quarkiverse/quarkus-jooq
将 DSLContext 注入 Spring Boot 仓库
jOOQ 最重要的接口之一是org.jooq.DSLContext。此接口代表使用 jOOQ 的起点,其主要目标是配置 jOOQ 在执行查询时的行为。此接口的默认实现名为DefaultDSLContext。在多种方法中,DSLContext可以通过org.jooq.Configuration对象、直接从 JDBC 连接(java.sql.Connection)、数据源(javax.sql.DataSource)以及用于将 Java API 查询表示形式转换为特定数据库 SQL 查询的方言(org.jooq.SQLDialect)来创建。
重要提示
对于java.sql.Connection,jOOQ 将为您提供对连接生命周期的完全控制(例如,您负责关闭此连接)。另一方面,通过javax.sql.DataSource获取的连接将在 jOOQ 查询执行后自动关闭。Spring Boot 喜欢数据源,因此连接管理已经处理(从连接池中获取和返回连接,事务开始/提交/回滚等)。
所有的 jOOQ 对象,包括DSLContext,都是从org.jooq.impl.DSL创建的。为了创建DSLContext,DSL类公开了一个名为using()的static方法,它有多种形式。其中最值得注意的是以下列出的:
// Create DSLContext from a pre-existing configuration
DSLContext ctx = DSL.using(configuration);
// Create DSLContext from ad-hoc arguments
DSLContext ctx = DSL.using(connection, dialect);
例如,连接到 MySQL 的classicmodels数据库可以这样做:
try (Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/classicmodels",
"root", "root")) {
DSLContext ctx =
DSL.using(conn, SQLDialect.MYSQL);
...
} catch (Exception e) {
...
}
或者,您可以通过数据源进行连接:
DSLContext ctx = DSL.using(dataSource, dialect);
例如,通过数据源连接到 MySQL 的classicmodels数据库可以这样做:
DSLContext getContext() {
MysqlDataSource dataSource = new MysqlDataSource();
dataSource.setServerName("localhost");
dataSource.setDatabaseName("classicmodels");
dataSource.setPortNumber("3306");
dataSource.setUser(props.getProperty("root");
dataSource.setPassword(props.getProperty("root");
return DSL.using(dataSource, SQLDialect.MYSQL);
}
但 Spring Boot 能够根据我们的数据库设置自动准备一个可注入的DSLContext。例如,Spring Boot 可以根据在application.properties中指定的 MySQL 数据库设置准备DSLContext:
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/
classicmodels?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=root
spring.jooq.sql-dialect=MYSQL
一旦 Spring Boot 检测到 jOOQ 的存在,它将使用前面的设置来创建org.jooq.Configuration,该配置用于准备一个可注入的DSLContext。
重要提示
虽然DSLContext具有高度的可配置性和灵活性,但 Spring Boot 仅进行最小努力来提供默认的DSLContext,该DSLContext可以立即注入和使用。正如您将在本书中看到的那样(但尤其是在官方 jOOQ 手册中 – www.jooq.org/doc/latest/manual/),DSLContext具有大量配置和设置,允许控制与我们的 SQL 语句相关的几乎所有操作。
Spring Boot 提供的DSLContext对象可以轻松注入到我们的持久化仓库中。例如,以下代码片段直接将这样的DSLContext对象注入到ClassicModelsRepository中:
@Repository
public class ClassicModelsRepository {
private final DSLContext ctx;
public ClassicModelsRepository(DSLContext ctx) {
this.ctx = ctx;
}
...
}
不要在这里得出结论,应用程序需要保留对 DSLContext 的引用。它仍然可以直接在局部变量中使用,就像你之前看到的那样(这意味着你可以有任意多的 DSLContext 对象)。这只意味着,在 Spring Boot 应用程序中,对于大多数常见场景,简单地像之前那样注入它会更方便。
在内部,jOOQ 可以使用 java.sql.Statement 或 PreparedStatement。默认情况下,并且出于非常好的原因,jOOQ 使用 PreparedStatement。
通常,DSLContext 对象被标记为 ctx(本书中使用)或 dsl。但 dslContext、jooq 和 sql 等其他名称也是不错的选择。基本上,你可以随意命名。
好的,到目前为止,一切顺利!在这个时候,我们可以访问 Spring Boot 提供的 DSLContext,这是基于我们在 application.properties 中的设置。接下来,让我们通过 jOOQ 的查询 DSL API 看看 DSLContext 的实际应用。
使用 jOOQ 查询 DSL API 生成有效的 SQL
使用 jOOQ 查询 DSL API 生成有效的 SQL 是探索 jOOQ 世界的一个良好开端。让我们从一个简单的 SQL 语句开始,并通过 jOOQ 来表达它。换句话说,让我们使用 jOOQ 查询 DSL API 将给定的 SQL 字符串查询表达为 jOOQ 面向对象的风格。考虑以下用 MySQL 语法编写的 SQL SELECT 语句:
SELECT * FROM `office` WHERE `territory` = ?
SQL 语句 SELECT * FROM `office` WHERE `territory` = ? 被写为一个普通的字符串。如果通过 DSL API 编写,jOOQ 可以生成此查询,如下所示(territory 绑定变量的值由用户提供):
ResultQuery<?> query = ctx.selectFrom(table("office"))
.where(field("territory").eq(territory));
或者,如果我们想让 FROM 子句更接近 SQL 的外观,我们可以这样写:
ResultQuery<?> query = ctx.select()
.from(table("office"))
.where(field("territory").eq(territory));
大多数模式都是不区分大小写的,但有一些数据库,如 MySQL 和 PostgreSQL,通常更倾向于小写,而其他数据库,如 Oracle,通常更倾向于大写。因此,按照 Oracle 风格编写前面的查询可以这样做:
ResultQuery<?> query = ctx.selectFrom(table("OFFICE"))
.where(field("TERRITORY").eq(territory));
或者,你可以通过显式调用 from() 来编写它:
ResultQuery<?> query = ctx.select()
.from(table("OFFICE"))
.where(field("TERRITORY").eq(territory));
jOOQ 流畅 API 是一件艺术品,看起来像流畅的英语,因此阅读和编写起来相当直观。
阅读前面的查询完全是英语:从 OFFICE 表中选择所有办公室,其中 TERRITORY 列等于给定的值。
很快,你就会对在 jOOQ 中编写这些查询的速度感到惊讶。
重要提示
正如你将在下一章中看到的,jOOQ 可以通过名为 jOOQ 代码生成器的功能生成基于 Java 的模式,该模式与数据库中的模式相对应。一旦启用此功能,编写这些查询将变得更加简单和清晰,因为将不再需要显式引用数据库模式,例如表名或表列。相反,我们将引用基于 Java 的模式。
此外,多亏了代码生成器功能,jOOQ 在几乎所有地方都为我们提前做出了正确的选择。我们不再需要关心查询的类型安全性和大小写敏感性,或者标识符的引号和限定。
jOOQ 代码生成器原子性地提升了 jOOQ 的功能并增加了开发者的生产力。这就是为什么推荐使用 jOOQ 代码生成器来充分利用 jOOQ。我们将在下一章中探讨 jOOQ 代码生成器。
接下来,必须对 jOOQ 查询(org.jooq.ResultQuery)执行数据库操作,并将结果集映射到用户定义的简单 POJO。
执行生成的 SQL 并映射结果集
通过 jOOQ 的 API 中的获取方法执行生成的 SQL 并将结果集映射到 POJO,可以这样做。例如,下面的代码片段依赖于 fetchInto() 方法:
public List<Office> findOfficesInTerritory(String territory) {
List<Office> result = ctx.selectFrom(table("office"))
.where(field("territory").eq(territory))
.fetchInto(Office.class);
return result;
}
那里发生了什么?!ResultQuery 去哪里了?这是黑魔法吗?显然不是!只是 jOOQ 在构建查询后立即获取了结果并将它们映射到了 Office POJO。是的,jOOQ 的 fetchInto(Office.class) 或 fetch().into(Office.class) 会正常工作。主要的是,jOOQ 通过以更面向对象的方式封装和抽象 JDBC 复杂性来执行查询并将结果集映射到 Office POJO。如果我们不想在构建查询后立即获取结果,则可以使用 ResultQuery 对象如下:
// 'query' is the ResultQuery object
List<Office> result = query.fetchInto(Office.class);
Office POJO 包含在这本书附带代码中。
重要提示
jOOQ 提供了一个全面的 API,用于将结果集获取和映射到集合、数组、映射等。我们将在后面的第八章“获取和映射”中详细说明这些方面。
完整的应用程序命名为 DSLBuildExecuteSQL。由于这可以用作存根应用程序,您可以在 Java/Kotlin 与 Maven/Gradle 的组合中找到它。这些应用程序(实际上,本书中的所有应用程序)使用 Flyway 进行模式迁移。您将看到,Flyway 和 jOOQ 是一对绝佳的搭档。
因此,在继续利用令人惊叹的 jOOQ 代码生成器功能之前,让我们快速总结本章内容。
摘要
注意,我们仅仅通过使用 jOOQ 生成和执行一个简单的 SQL 语句,就几乎触及了 jOOQ 功能的表面。尽管如此,我们已经强调 jOOQ 可以针对不同的方言生成有效的 SQL,并且可以以直接的方式执行和映射结果集。
在下一章中,我们将学习如何通过增加 jOOQ 的参与程度来更加信任 jOOQ。jOOQ 将代表我们生成类型安全的查询、POJO 和 DAO。
第二章:自定义 jOOQ 参与级别
在上一章中,我们介绍了在 Spring Boot 应用程序中使用 jOOQ 并用它来生成和执行一个有效的非类型安全的 SQL 语句。在本章中,我们将继续这一旅程,并通过一个惊人的特性——所谓的 jOOQ 代码生成器来提高 jOOQ 的参与级别。换句话说,jOOQ 将通过一个简单的流程来控制持久层,该流程从类型安全的查询开始,通过生成用于将查询结果映射为对象的 Plain Old Java Objects(POJOs)继续,并以生成用于在面向对象风格中简化最常见查询的 DAOs 结束。
到本章结束时,你将了解如何编写类型安全的查询,以及如何指导 jOOQ 在 Java 和 Kotlin 应用程序中生成具有自定义名称的 POJOs 和 DAOs,使用 Maven 和 Gradle。我们将声明性地(例如,在 XML 文件中)和程序性地涵盖这些主题。
本章将涵盖以下主题:
-
理解什么是类型安全的查询
-
生成基于 jOOQ 的 Java 架构
-
使用基于 Java 的架构编写查询
-
配置 jOOQ 以生成 POJOs
-
配置 jOOQ 以生成 DAOs
-
配置 jOOQ 以生成接口
-
处理程序配置
-
介绍 jOOQ 设置
让我们从对类型安全查询的简要讨论开始。
技术要求
本章使用的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter02
理解什么是类型安全的查询
一般而言,实际上什么是类型安全的 API?简而言之,如果一个 API 依赖于编程语言的类型系统,旨在防止和报告类型错误,那么它就是类型安全的。具体来说,jOOQ 通过代码生成器功能使编译器能够做到这一点。
使用类型安全的 SQL 是首选,因为不需要通过专门的测试来验证每个 SQL 语句,而且在编码期间修复问题比在应用程序运行时修复要快。例如,你可以显著减少专门用于 SQL 验证的单元测试数量,并专注于集成测试,这始终是一件好事。因此,SQL 类型安全确实很重要!
将 SQL 语句声明为 Java String 语句(例如,在 JPQL 风格中,它在执行时得到验证)没有利用类型安全。换句话说,编译器不能保证 SQL 语句的有效性。以下每个使用不同持久层选择的例子都会发生这种情况。所有这些例子都可以编译,但在运行时失败。
让我们看看一个 JdbcTemplate 非类型安全的 SQL 示例(绑定值的顺序错误):
public Manager findManager(Long id, String name) {
String sql = "SELECT * FROM MANAGER
WHERE MANAGER_ID=? AND MANAGER_NAME=?";
Manager result = jdbcTemplate
.queryForObject(sql, Manager.class, name, id);
}
在这里,我们有一个 Spring Data 示例(name 应该是 String,而不是 int):
@Query(value = "SELECT c.phone, p.cachingDate FROM Customer c
INNER JOIN c.payments p WHERE c.customer_name = ?1")
CustomerPojo fetchCustomerWithCachingDateByName(int name);
这里是一个 Spring Data 派生的查询方法示例(name 应该是 String,而不是 int):
Customer findByName(int name);
以下是一个没有代码生成器的 jOOQ 查询构建器示例(v应该替换为v.getOwnerName()):
public Customer findCustomer(Voucher v) {
ctx.select().from(table("CUSTOMER"))
.where(field("CUSTOMER.CUSTOMER_NAME").eq(v))...;
}
这里还有一个没有代码生成器的 jOOQ 查询构建器示例(在我们的模式中,没有OFFICES表和CAPACITY列):
ctx.select()
.from(table("OFFICES"))
.where(field("OFFICE.CAPACITY").gt(50));
这些只是一些简单的情况,容易发现和修复。想象一下一个非类型安全的复杂查询,其中包含大量的绑定。
但是,如果启用 jOOQ 代码生成器,那么 jOOQ 将针对一个实际基于 Java 的模式编译 SQL 语句,该模式反映了数据库。这样,jOOQ 确保至少以下内容:
-
SQL 中出现的类和字段存在,具有预期的类型,并且映射到数据库。
-
操作符和操作数之间没有类型不匹配。
-
生成的查询在语法上是有效的。
重要提示
我说“至少”是因为,除了类型安全之外,jOOQ 还关注许多其他方面,例如引号、限定符和标识符的大小写敏感性。这些方面在 SQL 方言之间处理起来并不容易,多亏了代码生成器功能,jOOQ 几乎在所有地方都为我们预先做出了正确的选择。正如 Lukas Eder 所说:“使用带有代码生成器的 jOOQ 只是稍微多一点的设置,但它将帮助 jOOQ 为许多愚蠢的边缘情况做出正确的、经过仔细选择的默认选择,这些边缘情况在以后处理起来非常令人烦恼。我强烈推荐! 😃”
回到类型安全,假设 jOOQ 代码生成器已经生成了所需的工件(一组类,反映了数据库表、列、过程、视图等)。在这种情况下,之前的 jOOQ 示例可以以类型安全的方式重写,如下所示。请注意,以下所有代码片段都无法编译:
import static jooq.generated.tables.Customer.CUSTOMER;
...
public Customer findCustomer(Voucher v) {
ctx.select().from(CUSTOMER)
.where(CUSTOMER.CUSTOMER_NAME.eq(v))...;
}
与原始示例相比,这个查询不仅更简洁,而且也是类型安全的。这次,CUSTOMER(替换了table("CUSTOMER"))是Customer类的static实例(快捷方式),代表customer表。此外,CUSTOMER_NAME(替换了field("CUSTOMER.CUSTOMER_NAME"))也是Customer类中的static字段,代表customer表的customer_name列。这些 Java 对象是由 jOOQ 代码生成器作为基于 Java 的模式的一部分生成的。注意这个static实例是如何名义上导入的——如果你觉得导入每个static工件的方法很麻烦,那么你可以简单地依靠导入整个模式作为import static jooq.generated.Tables.*的整洁技巧。
第二个 jOOQ 示例可以以类型安全的方式重写,如下所示:
import static jooq.generated.tables.Office.OFFICE;
...
ctx.select().from(OFFICES).where(OFFICE.CAPACITY.gt(50));
以下图是 IDE 的截图,显示编译器对这个 SQL 的类型安全提出异议:


图 2.1 – 编译器报告类型安全错误
重要提示
卢卡斯·埃德(Lukas Eder)说:“你可能知道,IDEs 帮助编写 SQL 和 JPQL 字符串,这是很好的。但是,当列名更改时,IDEs 不会使构建失败。”嗯,具有类型安全的查询涵盖了这一方面,IDE 可以导致构建失败。所以,多亏了 jOOQ 的流畅性和表达性,IDE 可以提供代码补全和重构支持。此外,在 jOOQ 中,绑定变量是动态抽象语法树(AST)的一部分;因此,不可能通过这种方式暴露 SQL 注入漏洞。
好的,但我们如何获取这个基于 Java 的架构呢?
生成 jOOQ 基于 Java 的架构
所有的先前查询都是通过将表名或列名放在引号之间并分别将它们作为参数传递给 jOOQ 内置的table()和field()方法来显式引用数据库架构的。
但是,使用 jOOQ 代码生成器允许通过 jOOQ 查询 DSL API 表达的 SQL 语句利用与数据库中镜像的基于 Java 的架构。代码生成部分是 jOOQ 生成工具的工作(其起点是org.jooq.codegen.GenerationTool类)。
拥有一个基于 Java 的架构非常有用。SQL 语句可以通过 Java 数据访问层表达并针对底层数据库架构执行。除了类型安全外,这些 SQL 语句不易出错,易于重构(例如,重命名列),并且比显式引用数据库架构更简洁。
jOOQ 提供了几种通过 jOOQ 代码生成器生成基于 Java 架构的解决方案。主要来说,jOOQ 可以通过直接对数据库应用逆向工程技术来生成基于 Java 的架构,也可以通过 DDL 文件、JPA 实体或包含架构的 XML 文件。接下来,我们将探讨前三种方法,从第一种方法开始,即直接从数据库生成基于 Java 的架构。主要我们会使用 Flyway 来迁移数据库(也支持 Liquibase),随后 jOOQ 将对其进行逆向工程以获取基于 Java 的架构。
直接从数据库生成代码
下图表示了 jOOQ 基于 Java 的架构生成流程:

图 2.2 – 基于 Java 的架构生成
到目前为止,jOOQ 每次应用程序启动(运行)时都会重新生成基于 Java 的架构。
换句话说,即使数据库架构没有发生变化,jOOQ 在每次运行时也会重新生成基于 Java 的架构。显然,这比仅在底层数据库架构缺失或发生变化(例如,向表中添加了新列)时重新生成基于 Java 的架构更可取;否则,这仅仅是一种浪费时间的行为。
有意识的模式变更管理是一件好事,拥有这样的工具真是太棒了!很可能会在 Flyway 和 Liquibase 之间做出选择。虽然我们将在下一节中仅涵盖 Flyway 方法,但 Liquibase 在 jOOQ 手册中得到了很好的介绍(www.jooq.org/doc/latest/manual/code-generation/codegen-liquibase/)。
使用 Maven 添加 Flyway
Flyway 是一个用于数据库迁移的出色工具(flywaydb.org/)。主要来说,Flyway 通过名为 flyway_schema_history 的表跟踪数据库模式变更(在 Flyway 版本 5 之前为 schema_version)。此表会自动添加到数据库中,并由 Flyway 本身维护。
通常情况下,在 Spring Boot 中,Flyway 会读取并执行位于指定路径(默认路径为 src/main/resources/db/migration)的所有数据库迁移脚本。例如,在这本书中,我们使用一个显式的路径指向根文件夹外的位置(${root}/db/migration)。我们这样做是因为我们希望避免在每个单独的应用程序中重复迁移脚本。要快速开始使用 Flyway,只需将以下依赖项添加到 pom.xml 中:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
Flyway 默认的 Maven 阶段用于 migrate 操作是 pre-integration-test(在 package 之后)。另一方面,jOOQ 需要在 generate-sources 阶段(在 validate 之后)执行迁移,因此要早得多。
主要来说,jOOQ 会针对 flyway_schema_history 表执行 SELECT 查询以检查模式版本。这意味着 jOOQ 需要等待迁移发生并且模式版本更新。如果版本已更新,那么 jOOQ 将重新生成基于 Java 的模式;否则,你会看到类似这样的消息:“现有版本 1.1 与模式 classicmodels 的 1.1 一致。忽略模式。”
在 generate-sources 阶段安排迁移可以通过 Flyway Maven 插件完成,如下所示:
<phase>generate-sources</phase>
让我们尝试使用 Gradle。
使用 Gradle 添加 Flyway
如果你更喜欢使用 Gradle,那么你将需要在以下代码中添加 build.gradle:
plugins {
id 'org.flywaydb.flyway' version '...'
}
dependencies {
implementation 'org.flywaydb:flyway-core'
}
flyway {
driver = ...
url = ...
...
}
接下来,让我们添加遵循 Flyway 命名约定的 SQL 脚本。
添加 Flyway 的 SQL 脚本
在本书开发的应用程序中,由 Flyway 读取和执行的脚本命名为 V1.1__Create.sql(此文件包含数据库模式的 DDLs)和 afterMigrate.sql(此文件包含用于填充数据库的 DMLs),并且放置在应用程序外部的 ${root}/db/migration 文件夹中。添加一个遵守 Flyway 命名约定的新文件(例如,V1.2__AddColumn.sql)将指示 Flyway 更新数据库模式,并使 jOOQ 重新生成基于 Java 的模式。只要没有迁移发生并且 jOOQ 生成的类存在,jOOQ 就不会重新生成基于 Java 的模式。
下图表示的流程对于大多数包含 DDL 变更的使用案例尤其有趣:

图 2.3 – Flyway 迁移和基于 jOOQ 的 Java 架构生成
注意 Flyway 迁移是在 jOOQ 代码生成之前发生的。最后,是时候启用 jOOQ 代码生成器了。
从开发者的角度来看,启用 jOOQ 代码生成器是一个设置任务,它通过在独立迁移脚本或 pom.xml(如果项目基于 Maven)或 build.gradle(如果项目基于 Gradle)中编写的代码片段来实现。jOOQ 读取这些信息,并据此配置和自动执行 org.jooq.codegen.GenerationTool 生成器。
使用 Maven 运行代码生成器
主要来说,jOOQ 代码生成器可以以独立模式或与 Maven/Gradle 一起运行。虽然这两种方法之间没有太大差异,但我们更喜欢使用 Maven 插件 jooq-codegen-maven 来进一步操作。不过,为了快速示例从命令行以独立模式运行代码生成器,你需要一个包含所有必需文件(包括 README 文件)的 ZIP 存档,名为 standalone-codegen-jooq.zip。这适用于 MySQL、PostgreSQL、SQL Server 和 Oracle。
现在,配置 jOOQ 的代码生成器需要一些可以打包到 XML 文件中的信息。这个文件的顶峰是 <configuration> 标签,用于塑造一个 org.jooq.meta.jaxb.Configuration 实例。请仔细阅读以下 jOOQ 代码生成器配置存根中的每个注释,因为每个注释都提供了关于其前一个标签的重要细节(在附带代码中,你会看到一个包含额外细节的注释扩展版本):
<plugin>
<groupId>...</groupId>
<artifactId>jooq-codegen-maven</artifactId>
<executions>
<execution>
<id>...</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration xmlns = "...">
<!-- Configure the database connection here -->
<jdbc>...</jdbc>
接下来,<generator/> 标签包含了所有用于自定义 jOOQ 生成器的信息:
<generator>
<!-- The Code Generator:
org.jooq.codegen.{Java/Kotlin/Scala}Generator
Defaults to org.jooq.codegen.JavaGenerator -->
<name>...</name>
<database>
<!-- The database type. The format here is:
org.jooq.meta.[database].[database]Database -->
<name>...</name>
<!-- The database schema-->
<inputSchema>...</inputSchema>
<!-- What should be included by the generator -->
<includes>...</includes>
<!-- What should be excluded by the generator -->
<excludes>...</excludes>
<!-- Schema version provider -->
<schemaVersionProvider>...</schemaVersionProvider>
<!-- Set generator queries timeout(default 5s) -->
<logSlowQueriesAfterSeconds>
...
</logSlowQueriesAfterSeconds>
</database>
<target>
<!-- The output package of generated classes -->
<packageName>...</packageName>
<!—The output directory of generated classes -->
<directory>...</directory>
</target>
</generator>
</configuration>
</execution>
</executions>
</plugin>
基于这个存根和注释,让我们尝试填充配置 jOOQ 代码生成器针对 MySQL 中的 classicmodels 数据库所缺失的部分:
<plugin>
<groupId>org.jooq</groupId>
<artifactId>jooq-codegen-maven</artifactId>
<executions>
<execution>
<id>generate-for-mysql</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration xmlns = "...">
<jdbc>
<driver>${spring.datasource.driverClassName}</driver>
<url>${spring.datasource.url}</url>
<user>${spring.datasource.username}</user>
<password>${spring.datasource.password}</password>
</jdbc>
<generator>
<name>org.jooq.codegen.JavaGenerator</name>
<database>
<name>org.jooq.meta.mysql.MySQLDatabase</name>
<inputSchema>classicmodels</inputSchema>
<includes>.*</includes>
<excludes>
flyway_schema_history | sequences
| customer_pgs | refresh_top3_product
| sale_.* | set_.* | get_.* | .*_master
</excludes>
<schemaVersionProvider>
SELECT MAX(`version`) FROM `flyway_schema_history`
</schemaVersionProvider>
<logSlowQueriesAfterSeconds>
20
</logSlowQueriesAfterSeconds>
</database>
<target>
<packageName>jooq.generated</packageName>
<directory>target/generated-sources</directory>
</target>
</generator>
</configuration>
</execution>
</executions>
</plugin>
为了简洁,这里没有列出 PostgreSQL、SQL Server 和 Oracle 的替代方案,但你可以在这本书附带的应用程序 WriteTypesafeSQL 中的代码中找到它们。
此外,Maven 插件在 <configuration> 中支持以下标志:
- 通过布尔属性/常量禁用插件:
<skip>false</skip>
- 使用外部 XML 配置而不是内联配置:
<configurationFile>${externalfile}</configurationFile>
- 或者,指定多个外部配置文件,使用 Maven 的
combine.children="append"策略合并:
<configurationFiles>
<configurationFile>${file1}</configurationFile>
<configurationFile>...</configurationFile>
</configurationFiles>
接下来,让我们通过 Gradle 运行 jOOQ 生成器。
使用 Gradle 运行代码生成器
通过 Gradle 运行代码生成器可以通过gradle-jooq-plugin实现(github.com/etiennestuder/gradle-jooq-plugin/)。以下代码片段代表了 Oracle 配置的巅峰:
dependencies {
jooqGenerator 'com.oracle.database.jdbc:ojdbc8'
jooqGenerator 'com.oracle.database.jdbc:ucp'
}
jooq {
version = '...'
edition = nu.studer.gradle.jooq.JooqEdition.TRIAL_JAVA_8
configurations {
main {
generateSchemaSourceOnCompilation = true // default
generationTool {
logging = org.jooq.meta.jaxb.Logging.WARN
jdbc {
driver = project.properties['driverClassName']
url = project.properties['url']
user = project.properties['username']
password = project.properties['password']
}
generator {
name = 'org.jooq.codegen.JavaGenerator'
database {
name = 'org.jooq.meta.oracle.OracleDatabase'
inputSchema = 'CLASSICMODELS'
includes = '.*'
schemaVersionProvider = 'SELECT MAX("version")
FROM "flyway_schema_history"'
excludes = '''\
flyway_schema_history | DEPARTMENT_PKG | GET_.*
| CARD_COMMISSION | PRODUCT_OF_PRODUCT_LINE
...
'''
logSlowQueriesAfterSeconds = 20
}
target {
packageName = 'jooq.generated'
directory = 'target/generated-sources'
}
strategy.name =
"org.jooq.codegen.DefaultGeneratorStrategy"
}
...
}
此外,我们必须将 jOOQ 生成器绑定到 Flyway 迁移工具,以便在真正需要时才执行它:
tasks.named('generateJooq').configure {
// ensure database schema has been prepared by
// Flyway before generating the jOOQ sources
dependsOn tasks.named('flywayMigrate')
// declare Flyway migration scripts as inputs on this task
inputs.files(fileTree('...'))
.withPropertyName('migrations')
.withPathSensitivity(PathSensitivity.RELATIVE)
// make jOOQ task participate in
// incremental builds and build caching
allInputsDeclared = true
outputs.cacheIf { true }
}
在捆绑的代码中,你可以找到针对 MySQL、PostgreSQL、SQL Server 和 Oracle 的完整应用程序(WriteTypesafeSQL),为 Java/Kotlin 和 Maven/Gradle 组合编写。
或者,如果你更喜欢Ant,那么请阅读这个:www.jooq.org/doc/latest/manual/code-generation/codegen-ant/。接下来,让我们探讨另一种生成基于 Java 的模式的方法。
从 SQL 文件(DDL)生成代码
另一种获取基于 Java 模式的 jOOQ 方法依赖于 DDL 数据库 API,它能够从包含数据库模式的 SQL 脚本(单个文件或增量文件)中完成这项任务。主要的是,jOOQ SQL 解析器将我们的 SQL 脚本实体化为内存中的 H2 数据库(Spring Boot 中开箱即用),生成工具将逆向工程它以输出基于 Java 的模式。以下图展示了这个流程:

图 2.4 – 通过 DDL 数据库 API 生成 jOOQ 基于 Java 的模式
DDL 数据库 API 配置的巅峰依赖于 jOOQ 元扩展,由org.jooq.meta.extensions.ddl.DDLDatabase表示。
使用 Maven 运行代码生成器
在这个上下文中,通过 Maven 运行代码生成器依赖于以下 XML 占位符。请阅读每个注释,因为它们包含有价值的信息(在捆绑的代码中,你会看到这些注释的扩展版本):
<configuration xmlns = "...">
<generator>
<name>...</name>
<database>
<name>org.jooq.meta.extensions.ddl.DDLDatabase</name>
<properties>
<!-- Specify the location of your SQL script -->
<property>
<key>scripts</key>
<value>...</value>
</property>
<!-- The sort order of scripts in a directory
(semantic, alphanumeric, flyway, none) -->
<property>
<key>sort</key>
<value>...</value>
</property>
<!-- The default schema for unqualified objects
(public, none) -->
<property>
<key>unqualifiedSchema</key>
<value>...</value>
</property>
<!-- The default name case for unquoted objects
(as_is, upper, lower) -->
<property>
<key>defaultNameCase</key>
<value>...</value>
</property>
</properties>
<inputSchema>PUBLIC</inputSchema>
<includes>...</includes>
<excludes>...</excludes>
<schemaVersionProvider>...</schemaVersionProvider>
<logSlowQueriesAfterSeconds>
...
</logSlowQueriesAfterSeconds>
</database>
<target>
<packageName>...</packageName>
<directory>...</directory>
</target>
</generator>
</configuration>
在这个上下文中,jOOQ 在不连接到真实数据库的情况下生成基于 Java 的模式。它使用 DDL 文件生成一个内存中的 H2 数据库,随后将其逆向工程为 Java 类。<schemaVersionProvider>标签可以绑定到一个 Maven 常量,你必须维护它以避免在没有任何变化时运行代码生成器。
除了这个占位符,我们还需要以下依赖项:
<dependency>
<groupId>org.jooq{.trial-java-8}</groupId>
<artifactId>jooq-meta-extensions</artifactId>
<version>${jooq.version}</version>
</dependency>
基于这个占位符和注释中的说明,让我们尝试填补缺失的部分以配置 jOOQ 代码生成器针对 PostgreSQL 中的classicmodels数据库:
<configuration xmlns = "...">
<generator>
<name>org.jooq.codegen.JavaGenerator</name>
<database>
<name>org.jooq.meta.extensions.ddl.DDLDatabase</name>
<properties>
<property>
<key>scripts</key>
<value>...db/migration/ddl/postgresql/sql</value>
</property>
<property>
<key>sort</key>
<value>flyway</value>
</property>
<property>
<key>unqualifiedSchema</key>
<value>none</value>
</property>
<property>
<key>defaultNameCase</key>
<value>lower</value>
</property>
</properties>
<inputSchema>PUBLIC</inputSchema>
<includes>.*</includes>
<excludes>
flyway_schema_history | akeys | avals | defined
| delete.* | department_topic_arr | dup
| ...
</excludes>
<schemaVersionProvider>
${schema.version} <!-- this is a Maven constant -->
</schemaVersionProvider>
<logSlowQueriesAfterSeconds>
20
</logSlowQueriesAfterSeconds>
</database>
<target>
<packageName>jooq.generated</packageName>
<directory>target/generated-sources</directory>
</target>
</generator>
</configuration>
Gradle 的替代方案包含在捆绑的代码中。
准备 SQL 文件
目前,使用一些供应商特定的东西是不可能的;因此,我们的 SQL 文件可能包含 jOOQ SQL 解析器可能不理解的部分。在这种情况下,我们必须通过以下示例中的 jOOQ 默认约定来准备我们的 SQL 文件,以分隔这些部分:
-- [jooq ignore start]
IF OBJECT_ID('payment', 'U') IS NOT NULL
DROP TABLE payment;
-- [jooq ignore stop]
-- [jooq ignore start] 和 -- [jooq ignore stop] 之间的代码会被 jOOQ SQL 解析器忽略。通过 parseIgnoreComments 布尔属性可以开启/关闭忽略这些标记之间的内容,而通过 parseIgnoreCommentStart 和 parseIgnoreCommentStop 属性可以自定义这些标记。更多详情请参阅 www.jooq.org/doc/latest/manual/code-generation/codegen-ddl/。
在捆绑的代码中,你可以看到针对 MySQL、PostgreSQL、SQL Server 和 Oracle 的此模板的实现,通过 Java/Kotlin 和 Maven/Gradle 组合,名称为 DeclarativeDDLDatabase。
在未来,虽然 jOOQ SQL 解析器将变得更加强大,但这将是推荐使用 jOOQ 代码生成器的做法。目标是让 jOOQ 默认执行更多迁移工作。
从实体(JPA)生成代码
假设你有一个依赖于实体模型(JPA 注解的实体)的 JPA 应用程序,并且你想要获取基于 Java 的 jOOQ 模式。如果你无法将 JPA 实体模型隔离在应用程序的单独模块中,那么你可以配置 jOOQ 直接从真实数据库(假设你在开发阶段可以访问真实数据库模式)或从 DDL 文件(假设你有这样的文件)生成基于 Java 的模式。但是,如果你可以轻松地将实体放置在应用程序的单独模块中,那么你可以依赖 jOOQ 的 JPA 数据库 API (org.jooq.meta.extensions.jpa.JPADatabase),它能够从 JPA 模型生成基于 Java 的模式。JPA 数据库 API 需要在单独的模块中包含实体,因为它必须通过 Spring 从类路径中查找它们。
以下图展示了 JPA 数据库 API 的流程:
![图 2.5 – 通过 JPA 数据库 API 生成 jOOQ 基于 Java 的架构]
![图 2.5 – 通过 JPA 数据库 API 生成 jOOQ 基于 Java 的架构]
图 2.5 – 通过 JPA 数据库 API 生成 jOOQ 基于 Java 的架构
JPA 数据库 API 的流程使用 Hibernate 内部生成内存中的 H2 数据库,从 JPA 模型(实体)中。随后,jOOQ 将此 H2 数据库反向工程为 jOOQ 类(基于 Java 的模式)。
使用 Maven 运行代码生成器
在此上下文中,通过 Maven 运行代码生成器依赖于以下 XML 模板。请阅读每个注释,因为它们包含有价值的信息(在捆绑的代码中,你可以找到这些注释的扩展版本):
<configuration xmlns="...">
<!-- JDBC connection to the H2 in-memory database -->
<jdbc>...</jdbc>
<generator>
<database>
<name>org.jooq.meta.extensions.jpa.JPADatabase</name>
<properties>
<!-- The properties prefixed with hibernate... or
javax.persistence... will be passed to Hibernate -->
<property>
<key>...</key>
<value>...</value>
</property>
<!-- Java packages (comma separated) that
contains your entities -->
<property>
<key>packages</key>
<value>...</value>
</property>
<!-- Whether JPA 2.1 AttributeConverters should
be auto-mapped to jOOQ Converters (default true) -->
<property>
<key>useAttributeConverters</key>
<value>...</value>
</property>
<!-- The default schema for unqualified objects
(public, none) -->
<property>
<key>unqualifiedSchema</key>
<value>...</value>
</property>
</properties>
<includes>...</includes>
<excludes>...</excludes>
<schemaVersionProvider>...</schemaVersionProvider>
<logSlowQueriesAfterSeconds>
...
</logSlowQueriesAfterSeconds>
</database>
<target>
<packageName>...</packageName>
<directory>...</directory>
</target>
</generator>
</configuration>
基于此模板和注释,以下是一个包含流行设置的示例(此片段是从一个使用 MySQL 作为真实数据库的 JPA 应用程序中提取的):
<configuration xmlns="...">
<jdbc>
<driver>org.h2.Driver</driver>
<url>jdbc:h2:~/classicmodels</url>
</jdbc>
<generator>
<database>
<name>org.jooq.meta.extensions.jpa.JPADatabase</name>
<properties>
<property>
<key>hibernate.physical_naming_strategy</key>
<value>
org.springframework.boot.orm.jpa
.hibernate.SpringPhysicalNamingStrategy
</value>
</property>
<property>
<key>packages</key>
<value>com.classicmodels.entity</value>
</property>
<property>
<key>useAttributeConverters</key>
<value>true</value>
</property>
<property>
<key>unqualifiedSchema</key>
<value>none</value>
</property>
</properties>
<includes>.*</includes>
<excludes>
flyway_schema_history | sequences
| customer_pgs | refresh_top3_product
| sale_.* | set_.* | get_.* | .*_master
</excludes>
<schemaVersionProvider>
${schema.version}
</schemaVersionProvider>
<logSlowQueriesAfterSeconds>
20
</logSlowQueriesAfterSeconds>
</database>
<target>
<packageName>jooq.generated</packageName>
<directory>target/generated-sources</directory>
</target>
</generator>
</configuration>
除了此模板外,我们还需要以下依赖项:
<dependency>
<groupId>org.jooq{.trial-java-8}</groupId>
<!-- before jOOQ 3.14.x, jooq-meta-extensions -->
<artifactId>jooq-meta-extensions-hibernate</artifactId>
<version>${jooq.meta.extensions.hibernate.version}
</version>
</dependency>
此方法和 Gradle 的替代方案在捆绑的 Java 和 Kotlin 代码中可用,名称为 DeclarativeJPADatabase。
你会发现另一种有趣的方法是从 XML 文件生成基于 Java 的模式:www.jooq.org/doc/latest/manual/code-generation/codegen-xml/。这在 DeclarativeXMLDatabase 和 ProgrammaticXMLGenerator 中得到了示例。
一般而言,强烈建议阅读 jOOQ 手册中的 代码生成 部分:www.jooq.org/doc/latest/manual/code-generation/。该部分包含大量影响生成的工件设置的配置。
如果你需要管理多个数据库、模式、目录、共享模式多租户等,请参阅 第十七章,jOOQ 中的多租户。
使用基于 Java 的模式编写查询
一旦 jOOQ 的代码生成器完成了其工作,我们就有了访问生成的工件。在这些工件中,我们有 jooq.generated.tables 文件夹,其中包含作为 Java 代码镜像的数据库表。生成的工件放置在指定的 /target 文件夹下(在我们的案例中,target/generated-sources),在指定的包名下(在我们的案例中,jooq.generated)。
重要提示
通常,你会指示 jOOQ 代码生成器将生成的代码存储在 /target 文件夹(Maven)、/build 文件夹(Gradle)或 /src 文件夹下。基本上,如果你选择 /target 或 /build 文件夹,那么 jOOQ 在每次构建时都会重新生成代码;因此,你可以确保源代码始终是最新的。然而,为了决定哪个路径最适合你的战略案例,建议阅读 Lukas Eder 在 Stack Overflow 上的回答:stackoverflow.com/questions/25576538/why-does-jooq-suggest-to-put-generated-code-under-target-and-not-under-src。还建议查看 jOOQ 手册中的 代码生成和版本控制 部分,该手册可在 www.jooq.org/doc/latest/manual/code-generation/codegen-version-control/ 找到。
记住,在前一章(第一章,开始使用 jOOQ 和 Spring Boot),我们已经使用了 jOOQ DSL API 来编写以下查询:
ResultQuery<?> query = ctx.selectFrom(table("office"))
.where(field("territory").eq(territory));
此查询引用了数据库模式(表和列)。重新编写此查询以引用基于 Java 的模式会产生以下代码(jOOQ Record,如 OfficeRecord,将在下一章介绍;现在,将其视为包装在 Java 对象中的结果集):
import static jooq.generated.tables.Office.OFFICE;
import jooq.generated.tables.records.OfficeRecord;
...
ResultQuery<OfficeRecord> query = ctx.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory));
或者,可以立即生成并执行查询,如下所示(Office 是一个 POJO):
public List<Office> findOfficesInTerritory(String territory) {
List<Office> result = ctx.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(Office.class);
return result;
}
根据数据库供应商的不同,生成的 SQL 看起来如下所示(注意 jOOQ 已经正确生成了适用于 MySQL 查询的特殊反引号):
SELECT
`classicmodels`.`office`.`office_code`,
`classicmodels`.`office`.`city`,
...
`classicmodels`.`office`.`territory`
FROM `classicmodels`.`office`
WHERE `classicmodels`.`office`.`territory` = ?
使用 PostgreSQL 生成的 SQL 如下所示(请注意,jOOQ 使用了包含 PostgreSQL 模式的限定符):
SELECT
"public"."office"."office_code",
"public"."office"."city",
...
"public"."office"."territory"
FROM "public"."office"
WHERE "public"."office"."territory" = ?
使用 Oracle 生成的 SQL 如下所示(请注意,jOOQ 将标识符转换为大写,这正是 Oracle 所偏好的):
SELECT
"CLASSICMODELS"."OFFICE"."OFFICE_CODE",
"CLASSICMODELS"."OFFICE"."CITY",
...
"CLASSICMODELS"."OFFICE"."TERRITORY"
FROM "CLASSICMODELS"."OFFICE"
WHERE "CLASSICMODELS"."OFFICE"."TERRITORY" = ?
使用 SQL Server 生成的 SQL 如下所示(请注意,jOOQ 使用了 SQL Server 特有的[]):
SELECT
[classicmodels].[dbo].[office].[office_code],
[classicmodels].[dbo].[office].[city],
...
[classicmodels].[dbo].[office].[territory]
FROM [classicmodels].[dbo].[office]
WHERE [classicmodels].[dbo].[office].[territory] = ?
因此,根据方言,jOOQ 生成了预期的查询。
重要提示
注意,selectFrom(table("OFFICE"))被渲染为*,而selectFrom(OFFICE)则被渲染为列名列表。在第一种情况下,jOOQ 无法从参数表推断出列;因此,它投影了*。在第二种情况下,由于基于 Java 的模式,jOOQ 从表中投影已知的列,从而避免了使用有争议的*。当然,*本身并不具有争议性——只是列名没有明确列出,正如这篇文章所解释的:tanelpoder.com/posts/reasons-why-select-star-is-bad-for-sql-performance/。
让我们尝试另一个查询ORDER表的例子。由于ORDER在大多数方言中都是保留词,让我们看看 jOOQ 将如何处理它。请注意,我们的查询没有做任何特殊的事情来指导 jOOQ 关于这个方面:
ResultQuery<OrderRecord> query = ctx.selectFrom(ORDER)
.where(ORDER.REQUIRED_DATE.between(startDate, endDate));
或者,立即生成并执行它(Order是一个 POJO):
public List<Order> findOrdersByRequiredDate(
LocalDate startDate, LocalDate endDate) {
List<Order> result = ctx.selectFrom(ORDER)
.where(ORDER.REQUIRED_DATE.between(startDate, endDate))
.fetchInto(Order.class);
return result;
}
让我们看看为 MySQL 生成的有效 SQL:
SELECT
`classicmodels`.`order`.`order_id`,
...
`classicmodels`.`order`.`customer_number`
FROM `classicmodels`.`order`
WHERE `classicmodels`.`order`.`required_date`
BETWEEN ? AND ?
为了简洁起见,我们将跳过为 PostgreSQL、Oracle 和 SQL Server 生成的 SQL。主要原因是 jOOQ 默认引用所有内容,因此我们可以以完全相同的方式使用保留和非保留名称,并得到有效的 SQL 语句。
让我们再举一个例子:
ResultQuery<Record2<String, LocalDate>> query = ctx.select(
CUSTOMER.CUSTOMER_NAME, ORDER.ORDER_DATE)
.from(ORDER)
.innerJoin(CUSTOMER).using(CUSTOMER.CUSTOMER_NUMBER)
.orderBy(ORDER.ORDER_DATE.desc());
或者,立即生成并执行它(CustomerAndOrder是一个 POJO):
public List<CustomerAndOrder> findCustomersAndOrders() {
List<CustomerAndOrder> result
= ctx.select(CUSTOMER.CUSTOMER_NAME, ORDER.ORDER_DATE)
.from(ORDER)
.innerJoin(CUSTOMER).using(CUSTOMER.CUSTOMER_NUMBER)
.orderBy(ORDER.ORDER_DATE.desc())
.fetchInto(CustomerAndOrder.class);
return result;
}
这个查询使用了JOIN...USING语法。基本上,你不需要通过ON子句提供条件,而是提供一组具有特殊重要性的字段——它们的名称在连接操作符的左右两侧的表中都是通用的。然而,一些方言(例如 Oracle)不允许我们在USING中使用限定名称。使用限定名称会导致错误,例如ORA-25154:USING 子句中的列部分不能有限定符。
jOOQ 了解这一点并采取行动。遵循 Oracle 方言,jOOQ 将CUSTOMER.CUSTOMER_NUMBER渲染为"CUSTOMER_NUMBER",而不是作为"CLASSICMODELS"."CUSTOMER"."CUSTOMER_NUMBER"进行限定。请在此处查看:
SELECT
"CLASSICMODELS"."CUSTOMER"."CUSTOMER_NAME",
"CLASSICMODELS"."ORDER"."ORDER_DATE"
FROM
"CLASSICMODELS"."ORDER"
JOIN "CLASSICMODELS"."CUSTOMER" USING ("CUSTOMER_NUMBER")
ORDER BY
"CLASSICMODELS"."ORDER"."ORDER_DATE" DESC
这只是一个例子,说明了 jOOQ 如何通过模拟正确的语法来照顾生成的 SQL,具体取决于所使用的方言!多亏了 jOOQ 的代码生成,我们受益于默认选择,这些选择对于以后处理来说非常繁琐。
让我们总结一下 jOOQ 代码生成带来的几个优点:
-
类型安全的 SQL 查询。我提到过类型安全的 SQL 查询吗?!
-
不必担心标识符的大小写敏感性、引号和限定符。
-
使用生成的代码可以使表达式更加简洁。减少了像
field("X", "Y")、field(name("X", "Y"))或field(name("X", "Y"), DATA_TYPE)这样的包装噪音。通过 jOOQ 代码生成,这将是X.Y。 -
IDE 可以提供代码补全和重构支持。
-
我们可以使用 IDE 来查找表和列的使用,因为它们是 Java 对象。
-
当列被重命名时,代码将不再编译,而不是必须运行查询才能失败。
-
避免由供应商特定数据类型引起的边缘情况问题。
-
由于 jOOQ 默认引用所有内容,用户不必考虑引用保留名称,例如
table(name("ORDER"))。它只是ORDER,jOOQ 将生成`ORDER`,"ORDER",[ORDER]或使用特定方言的任何内容。重要提示
作为一项经验法则,始终将 jOOQ 代码生成视为利用 jOOQ 的默认方式。当然,在某些情况下(例如,在运行时动态创建/修改的模式的情况下),代码生成无法完全利用,但这又是另一个故事。
本节开发的应用程序命名为 WriteTypesafeSQL。
jOOQ 与 JPA Criteria 与 QueryDSL 的比较
所有这三个,jOOQ、JPA Criteria(或基于 Criteria API 构建的 Spring Data JPA Specifications API)和 QueryDSL,都可以提供类型安全的 SQL。
如果你来自 JPA 背景,那么你知道 JPA 为 Criteria 查询定义了一个元模型 API。因此,Criteria API 和元模型 API 可以提供 SQL 的类型安全。但是,与 QueryDSL 相比,Criteria API 非常复杂。你不必相信我的话——试试看!然而,Criteria API 是你需要学习的内容,除了 JPQL 和所有 JPA 相关内容之外。此外,它不直观,文档编写得不好,开发者描述它非常慢。此外,100% 的类型安全意味着必须通过 Criteria API 编写所有容易出错的 SQL 语句。
QueryDSL 也支持 SQL 类型安全。由于 Spring Boot 的支持,QueryDSL 在本文中得到了很好的介绍,该文章在 dzone.com/articles/querydsl-vs-jooq-feature 中包含了一个关于 jOOQ 支持的非详尽列表,这些支持超出了 QueryDSL 的“功能完整性”。然而,那篇文章相当旧,可能已经过时。同时,jOOQ 有更多优势,你可以通过在 reddit.com 上快速搜索来找到它们。
接下来,让我们更进一步,给 jOOQ 更多控制权。
配置 jOOQ 生成 POJOs
到目前为止,我们一直使用我们自己的 POJOs 作为主要的 数据 传输对象(DTOs)。这在像 Spring Boot 应用程序这样的分层应用程序中是一种常见的方法。
Office和Order POJO 是OFFICE和ORDER表的 Java 镜像,因为我们的查询从这些表中获取所有列。另一方面,CustomerAndOrder POJO 映射来自两个不同表CUSTOMER和ORDER的列。更确切地说,它映射了CUSTOMER_NAME来自CUSTOMER和ORDER_DATE来自ORDER。
可选地,jOOQ 可以通过 jOOQ 代码生成器代表我们生成 POJO。在 Maven 中,可以通过以下配置在<generator>标签中启用此功能:
<generator>
...
<generate>
<pojos>true</pojos>
</generate>
...
</generator>
此外,jOOQ 可以为生成的 POJO 添加一组 Bean Validation API 注解来传达类型信息。更确切地说,它们包括两个著名的验证注解 – @NotNull (javax/jakarta.validation.constraints.NotNull) 和 @Size (javax/jakarta.validation.constraints.Size)。要启用这些注解,配置应如下所示:
<generate>
<pojos>true</pojos>
<validationAnnotations>true</validationAnnotations>
</generate>
此外,您应该添加validation-api的依赖项,如捆绑代码中所示。
默认情况下,生成的 POJO 的名称与表中文名称的Pascal大小写相同(例如,名为office_has_manager的表变为OfficeHasManager)。通过所谓的生成策略可以改变默认行为 – 基本上,在 Maven 中,一个由<strategy>标签定界的 XML 片段,它依赖于正则表达式来生成自定义(用户定义)输出。例如,如果 POJO 以Jooq文本为前缀,则生成策略将是以下内容:
<strategy>
<matchers>
<tables>
<table>
<pojoClass>
<expression>JOOQ_$0</expression>
<transform>PASCAL</transform>
</pojoClass>
...
</strategy>
这次,名为office_has_manager的表生成了一个名为JooqOfficeHasManager的 POJO 源。有关生成策略的更多详细信息(包括编程方法),请参阅第十八章,jOOQ SPI(提供者和监听器)。还建议阅读www.jooq.org/doc/latest/manual/code-generation/codegen-matcherstrategy/。
在捆绑代码中,还提供了 Gradle 的替代方案。
默认情况下,jOOQ 为数据库中的每一张表生成一个 POJO。因此,默认情况下,jOOQ 可以生成一个名为Office和Order(或JooqOffice和JooqOrder,符合前面的策略)的 POJO,但其目的不是生成更复杂的 POJO,例如复合 POJO 或包含任意对象的 POJO(例如CustomerAndOrder)。以下是由 jOOQ 生成的JooqOffice的源代码:
public class JooqOffice implements Serializable {
private static final long serialVersionUID = 1821407394;
private String officeCode;
private String city;
...
private String territory;
public JooqOffice() {}
public JooqOffice(JooqOffice value) {
this.officeCode = value.officeCode;
this.city = value.city;
...
this.territory = value.territory;
}
public JooqOffice(String officeCode,
String city, ... String territory) {
this.officeCode = officeCode;
this.city = city;
...
this.territory = territory;
}
@NotNull
@Size(max = 10)
public String getOfficeCode() {
return this.officeCode;
}
public void setOfficeCode(String officeCode) {
this.officeCode = officeCode;
}
// getters and setters and toString() omitted for brevity
}
对于classicmodels数据库中的每一张表,都会生成类似的 POJO。这意味着我们仍然可以使用我们的CustomerAndOrder POJO,但不需要为Office和Order编写自己的 POJO,因为我们可以使用 jOOQ 生成的那些。以下代码是从ClassicModelsRepository中裁剪出来的,并使用了生成的JooqOffice和JooqOrder(注意导入 – jOOQ 将 POJO 放在了jooq.generated.tables.pojos包中):
import jooq.generated.tables.pojos.JooqOffice;
import jooq.generated.tables.pojos.JooqOrder;
...
public List<JooqOffice> findOfficesInTerritory(
String territory) {
List<JooqOffice> result = ctx.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(JooqOffice.class);
return result;
}
public List<JooqOrder> findOrdersByRequiredDate(
LocalDatestartDate, LocalDateendDate) {
List<JooqOrder> result = ctx.selectFrom(ORDER)
.where(ORDER.REQUIRED_DATE.between(startDate, endDate))
.fetchInto(JooqOrder.class);
return result;
}
完成!因此,jOOQ 生成的 POJOs 可以像任何常规 POJOs 一样使用。例如,它们可以从 REST 控制器返回,Spring Boot 将它们序列化为 JSON。我们将在处理将结果集映射到 POJOs 的更多类型时详细介绍支持的 POJOs。
本节开发的程序以 GeneratePojos 可用。接下来,让我们看看 jOOQ 如何生成 DAO。
配置 jOOQ 生成 DAOs
如果你熟悉 Spring Data JPA/JDBC,那么你已经习惯了依赖于封装查询的 DAO 层。Spring Data JDBC 和 JPA 都提供内置的 DAO,它公开了一组 CRUD 操作,并且可以通过用户定义的仓库进行扩展。
jOOQ 代码生成可以产生类似的 DAOs。基本上,对于数据库中的每个表,jOOQ 可以生成一个 org.jooq.DAO 实现来公开方法,如 findById()、delete()、findAll()、insert() 和 update()。
在 Maven 中,此功能可以通过 <generator> 标签中的以下配置启用:
<generator>
...
<generate>
<daos>true</daos>
</generate>
...
</generator>
jOOQ DAOs 使用 POJOs;因此,jOOQ 将隐式生成 POJOs。由于我们使用 Spring Boot,生成的 DAOs 被注解为 @Repository,类似于内置的 SimpleJpaRepository。为了实现这一点,我们使用 <springAnnotations/> 标志,如下所示:
<generate>
<daos>true</daos>
<springAnnotations>true</springAnnotations>
</generate>
默认情况下,生成的 DAOs 的名称与表中 Pascal 案的名称相同,并附加 Dao(例如,名为 office_has_manager 的表变为 OfficeHasManagerDao)。通过所谓的 generator strategies 可以更改默认行为。例如,遵循 Spring 风格,我们更喜欢 OfficeHasManagerRepository 而不是 OfficeHasManagerDao。这可以通过以下方式实现:
<strategy>
<matchers>
<tables>
<table>
<daoClass>
<expression>$0_Repository</expression>
<transform>PASCAL</transform>
</daoClass>
...
</strategy>
在捆绑的代码中,Gradle 的替代方案可用。例如,生成的 OfficeRepository 看起来如下所示:
@Repository
public class OfficeRepository
extends DAOImpl<OfficeRecord, JooqOffice, String> {
public OfficeRepository() {
super(Office.OFFICE, JooqOffice.class);
}
@Autowired
public OfficeRepository(Configuration configuration) {
super(Office.OFFICE, JooqOffice.class, configuration);
}
@Override
public String getId(JooqOffice object) {
return object.getOfficeCode();
}
public List<JooqOffice> fetchRangeOfOfficeCode(
String lowerInclusive, String upperInclusive) {
return fetchRange(Office.OFFICE.OFFICE_CODE,
lowerInclusive, upperInclusive);
}
// more DAO-methods omitted for brevity
}
每个生成的 DAO 都扩展了名为 DAOImpl 的通用基类实现。这个实现提供了如 insert()、update()、delete() 和 findById() 等常用方法。
到目前为止,我们的 ClassicModelsRepository 包含三个查询方法,分别由 findOfficesInTerritory()、findOrdersByRequiredDate() 和 findCustomersAndOrders() 表示。
然而,让我们检查 findOfficesInTerritory() 的查询:
List<JooqOffice> result = ctx.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(JooqOffice.class);
在这里,我们注意到生成的 OfficeRepository 已经通过 fetchByTerritory(String territory) 方法覆盖了这个查询;因此,我们可以在我们的服务 ClassicModelsService 中直接使用这个内置 DAO 方法,如下所示:
@Transactional(readOnly = true)
public List<JooqOffice> fetchOfficesInTerritory(
String territory) {
return officeRepository.fetchByTerritory(territory);
}
进一步来说,查看 findOrdersByRequiredDate() 的查询:
List<JooqOrder> result = ctx.selectFrom(ORDER)
.where(ORDER.REQUIRED_DATE.between(startDate, endDate))
.fetchInto(JooqOrder.class);
这次,之前的查询通过内置 DAO 方法 fetchRangeOfRequiredDate(LocalDate li, LocalDate ui) 在 OrderRepository 中得到覆盖。因此,我们可以删除之前的查询,并依赖于 ClassicModelsService 的内置方法,如下所示:
@Transactional(readOnly = true)
public List<JooqOrder> fetchOrdersByRequiredDate(
LocalDate startDate, LocalDate endDate) {
return orderRepository.fetchRangeOfRequiredDate(
startDate, endDate);
}
到目前为止,ClassicModelsRepository 中剩下的唯一查询方法是 findCustomersAndOrders()。这个查询方法在默认生成的 DAO 中没有替代方案;因此,我们仍然需要它。
目前,您可以检查名为 GenerateDaos 的应用程序。稍后,我们将讨论扩展和自定义 jOOQ 生成的 DAO。
配置 jOOQ 以生成接口
除了 POJOs 和 DAOs 之外,jOOQ 可以为每个表生成一个接口。每个列都与一个 getter 和一个 setter 相关联。在 Maven 中,这可以像下面这样完成:
<generate>
<interfaces>true</interfaces>
<immutableInterfaces>true</immutableInterfaces>
</generate>
基本上,jOOQ 生成的接口看起来像 Spring Data 所谓的基于接口的封闭投影。我们可以使用这些接口来映射结果集,就像我们使用封闭投影一样。
然而,请注意,在撰写本文时,这个功能已被提议删除。您可以在以下位置跟踪弃用情况:github.com/jOOQ/jOOQ/issues/10509。
接下来,让我们继续配置 jOOQ 代码生成器的程序化配置。
处理程序化配置
如果您更喜欢程序化配置,那么 jOOQ 提供了一个流畅的 API(org.jooq.meta.jaxb.*),可以用于以程序化方式配置代码生成。首先,对于 Maven,在pom.xml中添加以下依赖项:
<dependency>
<groupId>org.jooq{.trial-java-8}</groupId>
<artifactId>jooq-codegen</artifactId>
</dependency>
或者,在 Gradle 中添加implementation 'org.jooq{.trial-java-8}:jooq-codegen'。
注意,Configuration指的是org.jooq.meta.jaxb.Configuration,而不是用于创建DSLContext和其他 jOOQ 上下文的org.jooq.Configuration。
这个程序化 API 反映了声明式方法,因此非常直观。例如,这里就是配置 jOOQ 以生成 DAOs部分中展示的声明式方法的程序化替代方案,针对 MySQL 的classicmodels模式:
Configuration configuration = new Configuration()
.withJdbc(new Jdbc()
.withDriver("com.mysql.cj.jdbc.Driver")
.withUrl("jdbc:mysql://localhost:3306/classicmodels")
.withUser("root")
.withPassword("root"))
.withGenerator(new Generator()
.withName("org.jooq.codegen.JavaGenerator")
.withDatabase(new Database()
.withName("org.jooq.meta.mysql.MySQLDatabase")
.withInputSchema("classicmodels")
.withIncludes(".*")
.withExcludes("flyway_schema_history | sequences"
+ " | customer_pgs | refresh_top3_product"
+ " | sale_.* | set_.* | get_.* | .*_master")
.withSchemaVersionProvider("SELECT MAX(`version`)
FROM `flyway_schema_history`")
.withLogSlowQueriesAfterSeconds(20))
.withGenerate(new Generate()
.withDaos(true)
.withValidationAnnotations(Boolean.TRUE)
.withSpringAnnotations(Boolean.TRUE))
.withStrategy(new Strategy()
.withMatchers(new Matchers()
.withTables(new MatchersTableType()
.withPojoClass(new MatcherRule()
.withExpression("Jooq_$0")
.withTransform(MatcherTransformType.PASCAL))
.withDaoClass(new MatcherRule()
.withExpression("$0_Repository")
.withTransform(MatcherTransformType.PASCAL)))))
.withTarget(new Target()
.withPackageName("jooq.generated")
.withDirectory(System.getProperty("user.dir")
.endsWith("webapp") ? "target/generated-sources"
: "webapp/target/generated-sources")));
GenerationTool.generate(configuration);
jOOQ 代码生成器必须在应用程序的类编译之前生成类;因此,程序化代码生成器应该放在应用程序的单独模块中,并在编译阶段之前适当的时刻调用。正如您将在捆绑代码(ProgrammaticGenerator)中看到的那样,这可以通过 Maven 的exec-maven-plugin或 Gradle 的JavaExec实现。
如果您更喜欢 DDL 数据库 API,那么您会喜欢ProgrammaticDDLDatabase中的程序化方法。如果您更喜欢 JPA 数据库 API,那么也请查看程序化方法,ProgrammaticJPADatabase。
本章中所有应用程序都适用于 Java/Kotlin 和 Maven/Gradle 组合。
介绍 jOOQ 设置
jOOQ 支持许多可选设置(org.jooq.conf.Settings),这些设置主要用于自定义渲染的 SQL。虽然所有这些设置都依赖于为广泛情况精心选择的默认值,但仍然存在我们必须更改它们的情况。
如果您更喜欢声明式方法,则可以通过名为 jooq-settings.xml 的 XML 文件来更改这些设置,该文件位于应用程序类路径中。例如,如果渲染的 SQL 不包含目录/模式名称,则 jooq-settings.xml 将如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<settings>
<renderCatalog>false</renderCatalog>
<renderSchema>false</renderSchema>
</settings>
没有这些设置,jOOQ 将渲染每个生成的 SQL 的目录/模式名称。以下是在 SQL Server 中的示例:
-
没有这些设置,jOOQ 将渲染
[classicmodels].[dbo].[customer].[customer_name]。 -
使用这些设置,jOOQ 不会渲染模式和目录名称 –
[customer].[customer_name]。
如您在相应的 XSD(https://www.jooq.org/xsd/jooq-runtime-3.x.x.x.xsd)中看到的,jOOQ 支持许多设置,其中大多数是为高级用户设计的,仅适用于某些场景。尽管如此,其中一些比其他更受欢迎,您将在本书的适当上下文中看到它们。
此外,jOOQ 的 Settings 可以通过 @Bean 进行编程式地定制,如下所示:
@Bean
public Settings jooqSettings() {
return new Settings()
.withRenderCatalog(Boolean.FALSE)
.withRenderSchema(Boolean.FALSE);
}
通过 @Bean,我们可以在全局(应用级别)定制 jOOQ 设置,但我们可以通过 DSLContext 构造函数(DSL.using())在 DSLContext 级别本地覆盖它们,如本例所示:
DataSource ds = ...;
DSLContext ctx = DSL.using(ds, SQLDialect.MYSQL,
new Settings()
.withRenderCatalog(Boolean.FALSE)
.withRenderSchema(Boolean.FALSE));
或者,我们可以在本地定义 DSLContext,它派生自当前的 DSLContext(表示为 ctx)并具有修改后的 Settings:
ctx.configuration().derive(
new Settings()
.withRenderCatalog(Boolean.FALSE)
.withRenderSchema(Boolean.FALSE))).dsl()
... // some query
在本书中,您将有很多机会看到 Settings 在实际中的应用,因此目前无需过于担心。
是时候总结本章内容了!
摘要
在本章中,我们达到了几个目标,但最重要的是介绍了使用配置和编程方法实现的 jOOQ 代码生成器。更具体地说,您看到了如何编写类型安全的查询,以及如何生成和使用 POJOs 和 DAOs。这些是 jOOQ 的基本技能,我们将在整本书中逐步发展。
从现在开始,我们将专注于其他有助于您成为 jOOQ 高级用户的话题。
在下一章中,我们将开始深入探讨 jOOQ 的核心概念。
第二部分:jOOQ 和查询
本部分涵盖了 jOOQ 类型安全的查询、流畅的 API、转换器、绑定、内联参数、映射器和关联。
本部分包含以下章节:
-
第三章, jOOQ 核心概念
-
第四章, 构建 DAO 层(生成 DAO 层的演变)
-
第五章, 处理不同类型的 SELECT、INSERT、UPDATE、DELETE 和 MERGE 语句
-
第六章, 处理不同类型的 JOIN 语句
-
第七章, 类型、转换器和绑定
-
第八章, 获取和映射
第三章:jOOQ 核心概念
在探索 jOOQ 更多精彩功能之前,我们必须了解 jOOQ 所依赖的核心(基本)概念。对 jOOQ 核心概念的深入理解有助于我们做出正确的决策,并理解 jOOQ 在底层是如何工作的。别担心,我们的目标不是深入 jOOQ 的内部!我们的目标是让你接近 jOOQ 范式,并开始从 jOOQ 的上下文考虑你的持久层。
本章的目标是简要介绍以下主题:
-
钩接 jOOQ 结果(
Result)和记录(Record) -
探索 jOOQ 查询类型
-
理解 jOOQ 流畅 API
-
强调 jOOQ 如何强调 SQL 语法的正确性
-
类型转换、强制转换和比较
-
绑定值(参数)
到本章结束时,你将熟悉 jOOQ 核心概念,这些概念将帮助你轻松地跟随接下来的章节。
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter03。
钩接 jOOQ 结果(Result)和记录(Record)
在前面的章节中,我们已经通过 jOOQ 的fetchInto()方法将我们的查询的 JDBC 结果集映射到 POJOs。但是,在 jOOQ 中,在 JDBC 结果集和众所周知的List<POJO>(或其他数据结构,如数组、映射和集合)之间,还有一个被称为Result<Record>的基本层,它由以下两个接口表示:
-
org.jooq.Record:当我们触发一个SELECT查询时,我们得到一个包含列列表和相应值列表的结果集。通常,我们将结果集的内容称为记录。jOOQ 将每个这样的记录映射到其Record接口。将Record视为 jOOQ 对记录的内部表示。 -
org.jooq.Result:jOOQ 的Result接口是一个java.util.List的org.jooq.Record。换句话说,jOOQ 将结果集的每个记录映射到Record,并将此记录收集到Result中。一旦Result<Record>完成(整个结果集已处理),它可以映射到数组、集合/列表的 POJOs、映射,或者可以按原样返回。
下图表示这条直接路径:JDBC 结果集 | jOOQ Result<Record> | 数组/列表/集合/映射:

图 3.1 – JDBC ResultSet 的处理
如您从这张图中可以看到,我们可以根据应用程序的需求以特定类型获取结果集(例如,List<POJO>),但也可以直接以Result<Record>获取结果集。如果您来自 JPA 领域,那么您可能会认为 jOOQ 的Record与 JPA 实体有些相似,但这并不正确。在 jOOQ 中,没有持久化上下文(一级缓存)的等价物,jOOQ 也不会对这些对象执行任何重负载操作,如状态转换和自动刷新。大多数时候,您可以直接通过 jOOQ API 使用记录,因为您甚至不需要 POJO。
重要提示
在 jOOQ 中,默认情况下,JDBC 结果集会积极加载到内存中(当前查询投影的所有数据都将存储在内存中),但正如您将在第八章中看到的,“获取和映射”,我们可以使用fetchLazy()和Cursor类型“懒惰”地操作大型结果集。将 JDBC 结果集映射到Result<Record>带来了多项好处,以下是我们强调的:
a) Result<Record>表示非类型安全的查询结果,但它也可以通过Record特殊化(如表记录、可更新记录和度记录,最高到 22 度(数字 22 来自 Scala – stackoverflow.com/q/6241441/521799))来表示类型安全的查询结果。
b) 在将Result<Record>完全加载到内存后,jOOQ 会尽早释放资源。在内存中操作Result<Record>比在保持数据库连接的 JDBC 结果集上操作更可取。
c) Result<Record>可以轻松导出为 XML、CSV、JSON 和 HTML。
d) jOOQ 提供了一个友好且全面的 API 来操作Result<Record>,因此,用于操作结果集。
jOOQ 支持以下几种类型的Record:
-
org.jooq.TableRecord和org.jooq.UpdatableRecord(可以再次存储在数据库中的记录)。TableRecord/UpdatableRecord记录来自具有主键的单个表(或视图)。只有UpdatableRecord有(为 jOOQ 所知)的主键。jOOQ 代码生成器可以代表我们生成此类记录 – 例如(查看我们之前的应用程序),jooq.generated.tables.records包,其中包含CustomerRecord、EmployeeRecord和OfficeRecord。所有这些表记录都是通过 jOOQ 生成器生成的,并且是强类型的。 -
Record的目的是为在 SQL 中投影自定义记录类型的查询提供类型安全。查询可以包含来自单个表或多个表的记录。jOOQ 将选择适当的Record1...Record22接口,并选择正确的类型以确保查询结果的安全性。重要提示
这种类型安全性应用于记录,直到 22 度。这也适用于行值表达式、通过集合运算符(例如,
UNION)组合的子查询、IN谓词和比较谓词(它们接受子查询)、以及接受类型安全VALUES()子句的INSERT和MERGE语句。超过 22 度,就没有类型安全性。 -
org.jooq.UDTRecordAPI。 -
org.jooq.EmbeddableRecord。这个主题在 第七章,类型、转换器和绑定 中有所介绍。
让我们看看几个获取 jOOQ 记录的示例。
通过普通 SQL 获取 Result
在 jOOQ 中,普通的 SQL,例如 SQL 字符串,返回一个匿名的类型安全 Result<Record>。以下有两个示例:
/* non type-safe Result<Record> */
Result<Record> result = ctx.fetch(
"SELECT customer_name, customer_number, credit_limit
FROM customer");
/* non type-safe Result<Record> */
Result<Record> result = ctx.resultQuery(
"SELECT customer_name, customer_number, credit_limit
FROM customer").fetch();
迭代 Result 就像迭代 java.util.List。每个 Record 都可以通过一个综合的 API 访问,该 API 提供了超过 10 个 get()/getValue() 方法,以非类型安全的方式从记录中检索值。考虑以下示例:
/* non type-safe values */
for (Record record : result) {
// get value by index
Object r1 = record.get(0);
// get value by name
Object r2 = record.get("customer_number");
// get value by name and type
BigDecimal r3 = record.getValue(
"credit_limit", BigDecimal.class);
}
注意 r3。我们的示例运行得很好,但如果指定的类型不是指定列的正确类型(换句话说,数据类型不能转换,但转换是可能的),那么我们将得到 jOOQ 的 DataTypeException,甚至更糟糕的是,你可能会静默地使用一个表面上成功的转换的结果,该结果可能有错误的表现。此外,列名中的错误或不存在的列将导致 java.lang.IllegalArgumentException。
为了避免这种不愉快的情况,从现在开始,我们依赖于通过 jOOQ Code Generator 获取的类。这为我们带来了巨大的生产力提升和广泛的功能。嗯,我告诉你你应该始终依赖 jOOQ Code Generator 吗?无论如何,让我们继续举例。
通过 select() 获取 Result
DSLContext 的无参数 select() 方法会导致包含所有列的投影。它还产生一个非类型安全的 Result<Record>。这次,我们使用由 jOOQ Code Generator 产生的基于 Java 的模式:
/* non type-safe Result<Record> */
Result<Record> result = ctx.select().from(CUSTOMER).fetch();
即使 Result<Record> 是非类型安全的,也可以通过 jOOQ 生成的类以类型安全的方式提取记录的值。更确切地说,我们使用生成的 Customer 类的属性如下(CUSTOMER 是 static):
/* type-safe values */
for (Record r : result) {
String r1 = r.get(CUSTOMER.CUSTOMER_NAME);
Long r2 = r.get(CUSTOMER.CUSTOMER_NUMBER);
BigDecimal r3 = r.get(CUSTOMER.CREDIT_LIMIT);
...
}
更进一步,我们可以将这个非类型安全的 Result<Record> 表达为类型安全的。
将 org.jooq.Record 映射到强类型的 org.jooq.TableRecord
由于我们从具有主键的单个表(CUSTOMER)中获取数据,我们可以使用 jOOQ 与数据库表关联的 TableRecord。
通过将 org.jooq.Record 映射到相应的强类型 org.jooq.TableRecord,可以通过 Record.into(Table<Z> table) 方法将先前的非类型安全的 Result<Record> 转换为类型安全的。在这种情况下,相应的强类型 org.jooq.TableRecord 是 CustomerRecord。查看以下代码:
/* type-safe Result<Record> */
Result<CustomerRecord> result = ctx.select().from(CUSTOMER)
.fetch().into(CUSTOMER);
同样可以通过Record.into(Class<? extends E> type)来实现:
/* type-safe Result<Record> */
List<CustomerRecord> result = ctx.select().from(CUSTOMER)
.fetch().into(CustomerRecord.class);
这次,我们可以使用CustomerRecord的 getter 来访问记录的值:
/* type-safe values */
for (CustomerRecord r : result) {
String r1 = r.getCustomerName();
Long r2 = r.getCustomerNumber();
BigDecimal r3 = r.getCreditLimit();
...
}
让我们看看如果我们将这个查询扩展以从两个(或更多)表中获取数据会发生什么。
通过 select()和 join()获取 Result
让我们通过添加JOIN子句来丰富ctx.select().from(CUSTOMER),以同时获取CUSTOMERDETAIL表中的记录(CUSTOMER和CUSTOMERDETAIL之间存在一对一的关系):
/* non type-safe Result<Record> */
Result<Record> result = ctx.select()
.from(CUSTOMER)
.join(CUSTOMERDETAIL)
.on(CUSTOMER.CUSTOMER_NUMBER
.eq(CUSTOMERDETAIL.CUSTOMER_NUMBER))
.fetch();
记录的值可以从生成的Customer和Customerdetail类的属性中类型安全地提取:
/* type-safe values */
for (Record r : result) {
String r1 = r.get(CUSTOMER.CUSTOMER_NAME);
Long r2 = r.get(CUSTOMER.CUSTOMER_NUMBER);
BigDecimal r3 = r.get(CUSTOMER.CREDIT_LIMIT);
...
String r4 = r.get(CUSTOMERDETAIL.CITY);
String r5 = r.get(CUSTOMERDETAIL.COUNTRY);
...
}
将这个非类型安全的Result<Record>重写为类型安全的版本稍微有点冗长。让我们看看如何做到这一点。
将 org.jooq.Record 映射到强类型 org.jooq.TableRecord
通过适当的select(SelectField<T1>, SelectField<T2>...SelectField<T22>)或into(Field<T1>, Field<T2> ... Field<T22>)方法以及适当的Record[N]接口(N=1..22),可以将之前的非类型安全的Result<Record>转换为类型安全的版本。我们的模式显示CUSTOMER和CUSTOMERDETAIL表总共包含 15 个字段,因此,适当的Record[N]是Record15,我们使用select(SelectField<T1>, SelectField<T2>... SelectField<T15>)对应的方法:
/* type-safe Result<Record> via select() */
Result<Record15<Long, String, String, String,
String, Long, BigDecimal, Integer, Long, String, String,
String, String, String, String>> result
= ctx.select(CUSTOMER.CUSTOMER_NUMBER,
CUSTOMER.CUSTOMER_NAME,CUSTOMER.CONTACT_FIRST_NAME,
CUSTOMER.CONTACT_LAST_NAME,CUSTOMER.PHONE,
CUSTOMER.SALES_REP_EMPLOYEE_NUMBER,
CUSTOMER.CREDIT_LIMIT,CUSTOMER.FIRST_BUY_DATE,
CUSTOMERDETAIL.CUSTOMER_NUMBER,
CUSTOMERDETAIL.ADDRESS_LINE_FIRST,
CUSTOMERDETAIL.ADDRESS_LINE_SECOND,
CUSTOMERDETAIL.CITY,CUSTOMERDETAIL.COUNTRY,
CUSTOMERDETAIL.POSTAL_CODE,CUSTOMERDETAIL.STATE)
.from(CUSTOMER)
.join(CUSTOMERDETAIL)
.on(CUSTOMER.CUSTOMER_NUMBER.eq(
CUSTOMERDETAIL.CUSTOMER_NUMBER))
.fetch();
或者,我们可以使用into(Field<T1>, Field<T2> ... Field<T15>)对应的方法:
/* type-safe Result<Record>via into() */
Result<Record15<Long, String, String, String,
String, Long, BigDecimal, Integer, Long, String,
String, String, String, String, String>> result =
ctx.select()
.from(CUSTOMER)
.join(CUSTOMERDETAIL)
.on(CUSTOMER.CUSTOMER_NUMBER
.eq(CUSTOMERDETAIL.CUSTOMER_NUMBER))
.fetch()
.into(CUSTOMER.CUSTOMER_NUMBER,
CUSTOMER.CUSTOMER_NAME, CUSTOMER.CONTACT_FIRST_NAME,
CUSTOMER.CONTACT_LAST_NAME,CUSTOMER.PHONE,
CUSTOMER.SALES_REP_EMPLOYEE_NUMBER, CUSTOMER.CREDIT_LIMIT,
CUSTOMER.FIRST_BUY_DATE, CUSTOMERDETAIL.CUSTOMER_NUMBER,
CUSTOMERDETAIL.ADDRESS_LINE_FIRST,
CUSTOMERDETAIL.ADDRESS_LINE_SECOND, CUSTOMERDETAIL.CITY,
CUSTOMERDETAIL.COUNTRY, CUSTOMERDETAIL.POSTAL_CODE,
CUSTOMERDETAIL.STATE);
显然,我们有 22 个这样的select()和into()方法,但我们需要与我们的记录度相对应的那个。
重要提示
你注意到Record15<…>结构了吗?当然注意到了!它很难错过!除了明显的冗长之外,填充数据类型也不是那么容易。你必须按正确的顺序识别并写下获取的字段的数据类型。幸运的是,我们可以通过使用 Java 9 的var关键字来避免这个痛苦的步骤。一旦你练习了本章的示例,并且熟悉了Record[N],那么在没有任何理由手动写下Record[N]的情况下,考虑使用var。另一方面,如果你使用 Kotlin/Scala,那么你可以利用对元组样式数据结构的更好支持,并依赖于Record[N]的自动解构为val(a, b, c) = select(A, B, C)。更多详情,请参考这个示例:github.com/jOOQ/jOOQ/tree/main/jOOQ-examples/jOOQ-kotlin-example。到目前为止,在 Java 中,前两个示例可以使用var如下表示:
var result = ctx.select(...);
var result = ctx.select()...into(...);
记录的值可以通过生成的Customer和Customerdetail类的属性以相同的方式访问。但是,我们能否通过相应的表记录来访问它?
从 Record 中提取两个 TableRecords
通过 Record.into(Table<Z> table) 方法从非规范化的 Record 中提取两个单独的强类型 TableRecord 类型(CustomerRecord 和 CustomerdetailRecord)是可以实现的。我敢打赌你没想到这是可能的:
Result<CustomerRecord> rcr=result.into(CUSTOMER);
Result<CustomerdetailRecord> rcd=result.into(CUSTOMERDETAIL);
此外,我们可以依赖 CustomerRecord 和 CustomerdetailRecord 的内置获取器来访问相应的值。
通过 selectFrom() 获取 Result
在类型安全的方式下选择单个表中的所有列并将其放入 Result<Record> 的最佳方法依赖于 selectFrom(table) 方法。在这种情况下,jOOQ 返回由参数表提供的记录类型,因此它返回 TableRecord。查看以下代码:
/* type-safe Result<Record> */
Result<CustomerRecord> result
= ctx.selectFrom(CUSTOMER).fetch();
此外,CustomerRecord 获取器返回的值:
/* type-safe values */
for (CustomerRecord r : result) {
String r1 = r.getCustomerName();
Long r2 = r.getCustomerNumber();
BigDecimal r3 = r.getCreditLimit();
...
}
虽然这确实很酷,但也请考虑以下重要注意事项。
重要注意事项
不要认为 select().from(table) 和 selectFrom(table) 是同一件事。前者,select().from(table),返回一个非类型安全的 Result<Record>,我们可以使用任何修改表表达式类型的子句(例如,JOIN)。另一方面,selectFrom(table) 返回一个类型安全的 Result<TableRecord>,不允许使用任何修改表表达式类型的子句。
接下来,让我们解决 ad hoc 查询。
通过 ad hoc 查询获取 Result
在 ad hoc 查询中,我们列出所需列,这些列可以来自一个或多个表。只要我们明确列出列并依赖基于 Java 的模式,jOOQ 就会确定正确的类型,并准备一定程度的记录。以下是一个从单个表中选择一些列的示例:
/* type-safe Result<Record> */
Result<Record3<Long, String, BigDecimal>> result = ctx.select(
CUSTOMER.CUSTOMER_NUMBER, CUSTOMER.CUSTOMER_NAME,
CUSTOMER.CREDIT_LIMIT)
.from(CUSTOMER)
.fetch();
由于我们有三个列,jOOQ 自动选择了度数为 3 的记录,Record3,并自动推断出正确的 Java 类型,Long,String 和 BigDecimal。
接下来,让我们看看一个示例,该示例从两个表中检索五个列:
/* type-safe Result<Record> */
Result<Record5<Long, BigDecimal, String, String, String>>
result = ctx.select(CUSTOMER.CUSTOMER_NUMBER,
CUSTOMER.CREDIT_LIMIT, CUSTOMERDETAIL.CITY,
CUSTOMERDETAIL.COUNTRY, CUSTOMERDETAIL.POSTAL_CODE)
.from(CUSTOMER)
.join(CUSTOMERDETAIL)
.on(CUSTOMER.CUSTOMER_NUMBER
.eq(CUSTOMERDETAIL.CUSTOMER_NUMBER))
.fetch();
这次,jOOQ 选择了 Record5<Long, BigDecimal, String, String, String>。我想你已经明白了这个概念!
以类型安全的方式访问记录的值可以通过生成类的属性来完成,或者你可以使用 Record.into(Table<Z> table) 来提取强类型的 TableRecords 并依赖相应的获取器。但请注意,只有查询中列出/投影的字段才填充了结果集的值。
通过 UDTs 获取 Result
UDTs 是 Oracle 和 PostgreSQL 正式支持的 ORDBMS 功能,并由 jOOQ 模拟为 UDTRecord。让我们考虑以下在 PostgreSQL 中定义的 UDT:
/* Define a type using CREATE TYPE */
CREATE TYPE "evaluation_criteria" AS ("communication_ability"
INT, "ethics" INT, "performance" INT, "employee_input" INT);
接下来,MANAGER 表模式如下使用此类型:
CREATE TABLE "manager" (
...
"manager_evaluation" evaluation_criteria DEFAULT NULL
...
);
运行 jOOQ 代码生成器会生成一个名为 EvaluationCriteria.java 的 org.jooq.UDT 实现文件(位于 jooq.generated.udt 包中)。除了 org.jooq.UDT 实现之外,还会生成一个名为 EvaluationCriteriaRecord.java 的 org.jooq.UDTRecord 实现文件(位于 jooq.generated.udt.records 包中)。
生成这些工件后,我们可以编写以下示例,它返回一个类型安全的 Result<Record>:
/* type-safe Result<Record> */
Result<Record2<String, EvaluationCriteriaRecord>> result =
ctx.select(MANAGER.MANAGER_NAME, MANAGER.MANAGER_EVALUATION)
.from(MANAGER)
.fetch();
可以通过以下方式访问记录的值。当然,高潮是访问 UDT 记录的值:
/* type-safe values */
for(Record2 r : result) {
String r1 = r.get(MANAGER.MANAGER_NAME);
Integer r2 = r.get(MANAGER.MANAGER_EVALUATION)
.getCommunicationAbility();
Integer r3 = r.get(MANAGER.MANAGER_EVALUATION)
.getEthics();
Integer r4 = r.get(MANAGER.MANAGER_EVALUATION)
.getPerformance();
Integer r5 = r.get(MANAGER.MANAGER_EVALUATION)
.getEmployeeInput();
}
或者,可以通过 Record.into(Table<Z> table) 来实现,如下所示:
/* type-safe Result<Record> */
Result<ManagerRecord> result =
ctx.select(MANAGER.MANAGER_NAME, MANAGER.MANAGER_EVALUATION)
.from(MANAGER)
.fetch()
.into(MANAGER); // or, into(ManagerRecord.class)
这次,可以通过 getManagerEvaluation() 方法访问记录的值:
/* type-safe values */
for(ManagerRecord r : result) {
String r1 =r.getManagerName();
Integer r2 =r.getManagerEvaluation()
.getCommunicationAbility();
Integer r3 = r.getManagerEvaluation().getEthics();
Integer r4 = r.getManagerEvaluation().getPerformance();
Integer r5 = r.getManagerEvaluation().getEmployeeInput();
}
好吧,这就是对 jOOQ 记录的简要概述。我故意跳过了 UpdatableRecord,因为这个主题将在 第九章,CRUD、事务和锁定 中进行讨论。
重要提示
当这本书编写时,尝试通过 Spring Boot 默认的 Jackson 功能(例如,通过从 REST 控制器返回 Record)将 jOOQ 记录序列化为 JSON/XML 将导致异常!设置 FAIL_ON_EMPTY_BEANS=false 将消除异常,但会导致奇怪且无用的结果。或者,你可以返回 POJO 或依赖 jOOQ 的格式化功能——正如你将看到的,jOOQ 可以将记录格式化为 JSON、XML 和 HTML。而且,别忘了使用 SQL/XML 或 SQL/JSON 功能,并在数据库中直接生成 JSON(参见 第八章,获取和映射)。然而,如果你真的想序列化 jOOQ 记录,那么你可以依赖 intoMap() 和 intoMaps(),正如你将在附带代码中看到的那样。同时,你可以在github.com/jOOQ/jOOQ/issues/11889上监控这个主题的进展。
本节中涵盖的示例可在书籍附带代码中的 Maven 和 Gradle 下找到,名称为 RecordResult。
探索 jOOQ 查询类型
jOOQ 区分了两种主要的查询类型:
-
DML(
INSERT、UPDATE、DELETE和MERGE等)和 DDL(CREATE、ALTER、DROP、RENAME等)查询,这些查询会在数据库中产生修改 -
DQL(
SELECT)查询会产生结果
DML 和 DDL 查询在 jOOQ 中由 org.jooq.Query 接口表示,而 DQL 查询由 org.jooq.ResultQuery 接口表示。ResultQuery 接口扩展了(包括)Query 接口。
例如,以下代码片段包含两个 jOOQ 查询:
Query query = ctx.query("DELETE FROM payment
WHERE customer_number = 103");
Query query = ctx.deleteFrom(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER.eq(103L));
这些查询可以通过 jOOQ 执行,并返回受影响的行数:
int affectedRows = query.execute();
此外,这里有两个结果查询:首先是一个普通的 SQL 查询——在这里,jOOQ 无法推断 Record 类型:
ResultQuery<Record> resultQuery = ctx.resultQuery(
"SELECT job_title FROM employee WHERE office_code = '4'");
Result<Record> fetched = resultQuery.fetch();
List<String> result = fetched.into(String.class);
其次,通过 jOOQ 生成的类表达式的 jOOQ ResultQuery(注意这次,jOOQ 推断出 ResultQuery 参数的数量和类型——因为我们只获取 JOB_TITLE,所以有 Record1<String>):
ResultQuery<Record1<String>> resultQuery
= ctx.select(EMPLOYEE.JOB_TITLE)
.from(EMPLOYEE)
.where(EMPLOYEE.OFFICE_CODE.eq("4"));
Result<Record1<String>> fetched = resultQuery.fetch();
List<String> result = fetched.into(String.class);
由于 ResultQuery 扩展了 Iterable,您可以使用 PL/SQL 风格的 foreach 来遍历查询,并对每条记录进行处理。例如,以下代码片段效果很好:
for (Record2<String, String> customer : ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.PHONE)
.from(CUSTOMER)) {
System.out.println("Customer:\n" + customer);
}
for (CustomerRecord customer : ctx.selectFrom(CUSTOMER)
.where(CUSTOMER.SALES_REP_EMPLOYEE_NUMBER.eq(1504L))) {
System.out.println("Customer:\n" + customer);
}
没有必要显式调用 fetch(),但您也可以这样做。本节中的示例被组织在一个名为 QueryAndResultQuery 的应用程序中。接下来,让我们谈谈 jOOQ 流畅 API。
理解 jOOQ 流畅 API
大多数时间花在 jOOQ 上是关于通过 jOOQ 流畅 API 编写流畅代码。这种方法对于构建避免打断或分块代码的流畅 SQL 表达式非常方便。此外,流畅 API 很容易通过更多操作来丰富。
基于接口驱动设计概念的出色实现,jOOQ 隐藏了大多数实现细节,并作为一个随时准备倾听您需要运行的 SQL 的 好朋友。让我们看看 jOOQ 流畅 API 的几个用法。
编写流畅查询
到目前为止,我们已经用 jOOQ DSL API 流畅风格编写了几个 SQL。让我们再看一个如下:
DSL.select(
ORDERDETAIL.ORDER_LINE_NUMBER,
sum(ORDERDETAIL.QUANTITY_ORDERED).as("itemsCount"),
sum(ORDERDETAIL.PRICE_EACH
.mul(ORDERDETAIL.QUANTITY_ORDERED)).as("total"))
.from(ORDERDETAIL)
.where((val(20).lt(ORDERDETAIL.QUANTITY_ORDERED)))
.groupBy(ORDERDETAIL.ORDER_LINE_NUMBER)
.orderBy(ORDERDETAIL.ORDER_LINE_NUMBER)
.getSQL();
将之前的 jOOQ 查询剖析到极致的目标离我们还很远,但让我们尝试从 jOOQ 的视角来了解这个查询。这将帮助您快速积累后续章节的信息,并增强您对 jOOQ 的信心。
大概来说,一个 JOOQ 流畅查询由两个基本构建块组成:org.jooq.QueryPart 接口作为通用基类型。让我们简要地介绍一下列表达式、表表达式和查询步骤,以便更好地理解这一段。
列表达式
org.jooq.Field 接口。列表达式有很多种,它们都可以用在各种 SQL 语句/子句中,以生成流畅的查询。例如,在 SELECT 子句中,我们有 org.jooq.SelectField(这是一个特殊的 org.jooq.Field 接口,用于 SELECT);在 WHERE 子句中,我们有 org.jooq.Field;在 ORDER BY 子句中,我们有 org.jooq.OrderField;在 GROUP BY 子句中,我们有 org.jooq.GroupField;在条件和函数中,我们通常有 org.jooq.Field。
列表达式可以通过 jOOQ 流畅 API 随意构建,以形成不同的查询部分,如算术表达式(例如,column_expression_1.mul(column_expression_2))、用于 WHERE 和 HAVING 的条件/谓词(例如,这里是一个等价条件:WHERE(column_expression_1.eq(column_expression_2)))等等。
当列表达式引用表列时,它们被引用为 org.jooq.TableField。这类列表达式是由 jOOQ 代码生成器内部生成的,你可以在每个特定于表的 Java 类中看到它们。TableField 的实例不能直接创建。
让我们使用以下图示来识别查询中的列表达式类型,它将它们突出显示:
![Figure 3.2 – 识别此查询的列表达式
![img/B16833_Figure_3.2.jpg]
Figure 3.2 – 识别此查询的列表达式
首先,我们有一些引用 ORDERDETAIL 表的表列:
Field<Integer> tc1 = ORDERDETAIL.ORDER_LINE_NUMBER;
Field<Integer> tc2 = ORDERDETAIL.QUANTITY_ORDERED;
Field<BigDecimal> tc3 = ORDERDETAIL.PRICE_EACH;
我们已经将其提取为 TableField:
TableField<OrderdetailRecord,Integer>
tfc1 = ORDERDETAIL.ORDER_LINE_NUMBER;
TableField<OrderdetailRecord,Integer>
tfc2 = ORDERDETAIL.QUANTITY_ORDERED;
TableField<OrderdetailRecord,BigDecimal>
tfc3 = ORDERDETAIL.PRICE_EACH;
我们还有一个未命名的列表达式:
Field<Integer> uc1 = val(20);
仅仅作为一个快速提示,在这里,DSL.val() 方法简单地创建代表常量值的 Field<Integer>(获取绑定值作为 Param<Integer>,其中 Param 扩展 Field),我们将在本章稍后讨论 jOOQ 参数。
让我们使用提取的列表达式重写到目前为止的查询:
DSL.select(tc1, sum(tc2).as("itemsCount"),
sum(tc3.mul(tc2)).as("total"))
.from(ORDERDETAIL)
.where(uc1.lt(tc2))
.groupBy(tc1)
.orderBy(tc1)
.getSQL();
接下来,让我们提取 sum() 聚合函数的使用情况。sum() 的第一次使用依赖于一个表列表达式(tc2)来生成一个函数表达式:
Field<BigDecimal> f1 = sum(tc2); // function expression
sum() 的第二种用法封装了一个使用两个表列表达式(tc3 和 tc2)的算术表达式,因此,它可以如下提取:
Field<BigDecimal> m1 = tc3.mul(tc2); // arithmetic expression
Field<BigDecimal> f2 = sum(m1); // function expression
再进一步,我们注意到我们的查询使用了 f1 和 f2 的别名,因此,这些可以提取为带别名的表达式:
Field<BigDecimal> a1 = f1.as("itemsCount"); // alias expression
Field<BigDecimal> a2 = f2.as("total"); // alias expression
让我们再次重写查询:
DSL.select(tc1, a1, a2)
.from(ORDERDETAIL)
.where(uc1.lt(tc2))
.groupBy(tc1)
.orderBy(tc1)
.getSQL();
完成!在这个时候,我们已经识别了我们查询中的所有列表达式。那么表表达式呢?
表表达式
除了字段之外,表也代表了任何查询的基本构建块。jOOQ 通过 org.jooq.Table 来表示表。在我们的查询中,有一个单独的表引用:
.from(ORDERDETAIL) // table expression ORDERDETAIL
它可以如下提取:
// non type-safe table expression
Table<?> t1 = ORDERDETAIL;
// type-safe table expression
Table<OrderdetailRecord> t1 = ORDERDETAIL;
这次,查询变成了以下形式:
DSL.select(tc1, a1, a2)
.from(t1)
.where(uc1.lt(tc2))
.groupBy(tc1)
.orderBy(tc1)
.getSQL();
jOOQ 支持广泛的表,不仅包括数据库表,还包括普通 SQL 表、别名表、派生表、公用表表达式(CTEs)、临时表和表值函数。但我们将讨论这些内容在接下来的章节中。
到目前为止,请注意,我们还没有触及 uc1.lt(tc2)。正如你可能直觉到的,这是一个使用两个列表达式并映射为 jOOQ 的 org.jooq.Condition 的条件。它可以如下提取:
Condition c1 = uc1.lt(tc2); // condition
在提取所有这些部分之后,我们得到了以下查询:
DSL.select(tc1, a1, a2)
.from(t1)
.where(c1)
.groupBy(tc1)
.orderBy(tc1)
.getSQL();
实际上,你甚至可以这样做,但这样就没有更多的类型安全性:
Collection<? extends SelectField> sf = List.of(tc1, a1, a2);
DSL.select(sf) …
显然,这些查询部分也可以用来形成其他任意查询。毕竟,在 jOOQ 中,我们可以编写 100% 动态的查询。
重要提示
在 jOOQ 中,即使它们看起来像静态查询(由于 jOOQ 的 API 设计),每个 SQL 都是动态的,因此,它可以被分解成可以流畅地重新组合到任何有效 jOOQ 查询中的查询部分。我们将在稍后讨论动态过滤器时提供更多示例。
最后,让我们快速了解一下查询步骤主题。
查询步骤(SelectFooStep、InsertFooStep、UpdateFooStep 和 DeleteFooStep)
继续识别剩余的查询部分,我们有select、from、where、groupBy和orderBy。这些部分逻辑上链接在一起形成我们的查询,并由 jOOQ 表示为查询步骤。查询步骤有很多种类型,但我们的查询可以分解如下:
SelectSelectStep s1 = DSL.select(tc1, a1, a2);
SelectJoinStep s2 = s1.from(t1);
SelectConditionStep s3 = s2.where(c1);
SelectHavingStep s4 = s3.groupBy(tc1);
SelectSeekStep1 s5 = s4.orderBy(tc1);
return s5.getSQL();
或者,用作类型安全步骤的如下(记住,你可以使用 Java 9 var代替SelectSelectStep<Record3<Short, BigDecimal, BigDecimal>>):
SelectSelectStep<Record3<Integer, BigDecimal, BigDecimal>>
s1ts = DSL.select(tc1, a1, a2);
SelectJoinStep<Record3<Integer, BigDecimal, BigDecimal>>
s2ts = s1ts.from(t1);
SelectConditionStep<Record3<Integer, BigDecimal, BigDecimal>>
s3ts = s2ts.where(c1);
SelectHavingStep<Record3<Integer, BigDecimal, BigDecimal>>
s4ts = s3ts.groupBy(tc1);
SelectSeekStep1<Record3<Integer, BigDecimal, BigDecimal>,
Integer> s5ts = s4ts.orderBy(tc1);
return s5ts.getSQL();
查看此代码片段的最后一行。我们返回生成的有效 SQL 作为纯字符串,而不执行此查询。执行可以在数据库连接存在的情况下发生,因此,我们需要配置DSLContext来完成此任务。如果我们已经注入了DSLContext,那么我们只需要像下面这样使用它:
return ctx.fetch(s5); // or, s5ts
或者,我们可以这样使用它:
SelectSelectStep s1 = ctx.select(tc1, a1, a2);
// or
SelectSelectStep<Record3<Integer, BigDecimal, BigDecimal>>
s1ts = ctx.select(tc1, a1, a2);
这个SelectSelectStep包含对DSLContext配置的内部引用,因此,我们可以将最后一行替换如下:
return s5.fetch(); // or, s5ts
完整的代码可以在本书附带代码中找到,这些代码打包在名为FluentQueryParts的部分下。虽然在本节中,你看到了如何分解查询步骤,但请记住,几乎总是更好的选择是依赖于动态 SQL 查询而不是引用这些步骤类型。因此,作为一个经验法则,总是尝试避免直接分配或引用查询步骤。
显然,将查询分解成部分并不是日常任务。大多数时候,你只会使用流畅 API,但有时了解如何进行分解是有用的(例如,这有助于编写动态过滤器,在查询的不同位置引用别名,在多个位置重用查询部分,以及编写相关子查询)。
jOOQ 流畅 API 的另一个用途是关注DSLContext的创建。
创建DSLContext
很可能,在 Spring Boot 应用程序中,我们更喜欢注入默认的DSLContext,正如你在第一章“启动 jOOQ 和 Spring Boot”和第二章“自定义 jOOQ 的参与级别”中看到的,但,在某些情况下(例如,使用自定义设置包装和运行特定查询,以不同于默认方言的方言渲染 SQL,或者需要偶尔对未在 Spring Boot 中配置的数据库执行查询),我们更喜欢将DSLContext作为局部变量使用。这可以通过DSL.using()方法以流畅风格完成,如下列非详尽的示例所示。
从数据源和方言创建DSLContext
有DataSource(例如,注入到你的仓库中),我们可以创建DSLContext并以流畅风格执行查询,如下所示:
private final DataSource ds; // injected DataSource
...
List<Office> result = DSL.using(ds, SQLDialect.MYSQL)
.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(Office.class);
此示例依赖于DSL.using(DataSource datasource, SQLDialect dialect)方法。
从数据源、方言和一些设置创建 DSLContext
启用/禁用前一个示例中的某些设置需要我们实例化org.jooq.conf.Settings。这个类提供了一个全面的流畅 API(通过withFoo()方法),它影响 jOOQ 渲染 SQL 代码的方式。例如,以下代码片段阻止了模式名称的渲染(只需看看这段流畅的代码):
private final DataSource ds; // injected DataSource
...
List<Office> result = DSL.using(ds, SQLDialect.MYSQL,
new Settings().withRenderSchema(Boolean.FALSE))
.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(Office.class);
此示例依赖于using(DataSource datasource, SQLDialect dialect, Settings settings)方法。
修改注入的 DSLContext 的设置
在前面的示例中,我们创建了不渲染模式名称的DSLContext。此设置应用于创建的DSLContext的所有使用,换句话说,应用于在此DSLContext配置下触发的所有查询。我们如何为 Spring Boot 注入到仓库后的默认DSLContext做同样的事情?以下代码提供了答案:
private final DSLContext ctx; // injected DSLContext
...
List<Office> result = ctx.configuration()
.set(new Settings().withRenderSchema(Boolean.FALSE)).dsl()
.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(Office.class);
主要地,我们通过configuration()访问注入的DSLContext的当前配置,设置我们的设置,并调用dsl()方法以获取对DSLContext的访问。请注意,从这一点开始,所有使用ctx的地方将不会渲染模式名称,除非你再次启用它。如果你希望为某个特定的查询使用一些特定的设置,那么可以通过derive()代替set()从注入的一个DSLContext派生出来。这样,原始的DSLContext保持不变,你可以操作派生出来的一个:
private final DSLContext ctx; // injected DSLContext
...
List<Office> result = ctx.configuration()
.derive(new Settings().withRenderSchema(Boolean.FALSE)).dsl()
.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(Office.class);
因此,在前面的示例中,ctx保持不变,jOOQ 使用派生的DSLContext,它将不会渲染模式名称。
从连接创建 DSLContext
从连接创建 DSLContext 并以流畅风格执行查询可以按以下方式完成:
try ( Connection conn
= DriverManager.getConnection(
"jdbc:mysql://localhost:3306/classicmodels",
"root", "root")) {
List<Office> result = DSL.using(conn)
.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(Office.class);
return result;
} catch (SQLException ex) { // handle exception }
在这种情况下,我们必须手动关闭连接;因此,我们使用了try-with-resources技术。此示例依赖于DSL.using(Connection c)方法。如果你想指定 SQL 方言,那么尝试使用DSL.using(Connection c, SQLDialect d)。
从 URL、用户和密码创建 DSLContext
对于基于独立脚本的脚本,由于连接与脚本本身一样长,处理资源并不重要,我们可以依赖DSL.using(String url)、DSL.using(String url, Properties properties)和DSL.using(String url, String user, String password)。
如果你更喜欢在 jOOQ 3.14 之前使用DSL.using(String url, String user, String password)方法(或任何其他两个方法),那么你必须显式关闭连接。这可以通过显式调用DSLContext.close()或使用try-with-resources来实现。从 jOOQ 3.14 开始,这些DSL.using()的重载将产生新的CloseableDSLContext类型,它允许我们编写如下代码:
try (CloseableDSLContext cdctx = DSL.using(
"jdbc:mysql://localhost:3306/classicmodels",
"root", "root")) {
List<Office> result = cdctx.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.fetchInto(Office.class);
return result;
}
接下来,让我们看看如何在不使用数据库连接的情况下使用DSLContext。
以特定方言渲染 SQL
在特定的方言(这里,MySQL)中渲染 SQL 可以通过以下流畅的代码完成:
String sql = DSL.using(SQLDialect.MYSQL)
.selectFrom(OFFICE)
.where(OFFICE.TERRITORY.eq(territory))
.getSQL();
由于没有连接或数据源,因此没有与数据库的交互。返回的字符串表示针对提供的方言生成的特定 SQL。此示例依赖于 DSL.using(SQLDialect dialect) 方法。
你可以在本书附带代码中找到所有这些示例,名称为 CreateDSLContext。
使用 Lambda 和流
jOOQ 流畅 API、Java 8 Lambda 和流组成了一支完美的团队。让我们看看几个演示这一点的例子。
使用 Lambda
例如,jOOQ 提供了一个名为 RecordMapper 的功能接口,用于将 jOOQ 记录映射到 POJO。让我们假设我们有以下 POJO。首先,让我们假设我们有 EmployeeName:
public class EmployeeName implements Serializable {
private String firstName;
private String lastName;
// constructors, getters, setters,... omitted for brevity
}
接下来,让我们假设我们有 EmployeeData:
public class EmployeeData implements Serializable {
private Long employeeNumber;
private int salary;
private EmployeeName employeeName;
// constructors, getters, setters,... omitted for brevity
}
接下来,让我们假设我们有以下原始 SQL:
SELECT employee_number, salary, first_name, last_name
FROM employee
通过 fetch(String sql) 风味和 map(RecordMapper<? super R,E> rm)(如下所示)执行和映射这个原始 SQL 是可行的:
List<EmployeeData> result
= ctx.fetch("SELECT employee_number, first_name,
last_name, salary FROM employee")
.map(
rs -> new EmployeeData(
rs.getValue("employee_number", Long.class),
rs.getValue("salary", Integer.class),
new EmployeeName(
rs.getValue("first_name", String.class),
rs.getValue("last_name", String.class))
)
);
如果通过基于 Java 的模式表达原始 SQL,则同样适用:
List<EmployeeData> result
= ctx.select(EMPLOYEE.EMPLOYEE_NUMBER,
EMPLOYEE.FIRST_NAME,EMPLOYEE.LAST_NAME,
EMPLOYEE.SALARY)
.from(EMPLOYEE)
.fetch()
.map(
rs -> new EmployeeData(
rs.getValue(EMPLOYEE.EMPLOYEE_NUMBER),
rs.getValue(EMPLOYEE.SALARY),
new EmployeeName(rs.getValue(EMPLOYEE.FIRST_NAME),
rs.getValue(EMPLOYEE.LAST_NAME))
)
);
如果通过 fetch(RecordMapper<? super R,E> rm) 更简洁地表达,则也适用:
List<EmployeeData> result
= ctx.select(EMPLOYEE.EMPLOYEE_NUMBER, EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, EMPLOYEE.SALARY)
.from(EMPLOYEE)
.fetch(
rs -> new EmployeeData(
rs.getValue(EMPLOYEE.EMPLOYEE_NUMBER),
rs.getValue(EMPLOYEE.SALARY),
new EmployeeName(rs.getValue(EMPLOYEE.FIRST_NAME),
rs.getValue(EMPLOYEE.LAST_NAME))
)
);
如果你认为这些映射对于使用自定义的 RecordMapper 来说太简单了,那么你是正确的。在我们详细讨论映射时,你将看到更多适合自定义记录映射的案例。对于这个案例,两者都可以通过使用内置的 into() 和 fetchInto() 方法,通过别名添加提示来解决问题。首先,我们可以丰富原始的 SQL(对于 MySQL,我们使用反引号):
List<EmployeeData> result = ctx.fetch("""
SELECT employee_number, salary,
first_name AS `employeeName.firstName`,
last_name AS `employeeName.lastName`
FROM employee""").into(EmployeeData.class);
然后,我们可以丰富 jOOQ SQL:
List<EmployeeData> result
= ctx.select(EMPLOYEE.EMPLOYEE_NUMBER, EMPLOYEE.SALARY,
EMPLOYEE.FIRST_NAME.as("employeeName.firstName"),
EMPLOYEE.LAST_NAME.as("employeeName.lastName"))
.from(EMPLOYEE)
.fetchInto(EmployeeData.class);
让我们看看更多使用 Lambda 的例子。
以下代码片段打印出所有销售记录。由于 selectFrom() 返回带有参数表的记录类型,因此此代码打印出每个 SaleRecord(注意调用 fetch() 是可选的):
ctx.selectFrom(SALE)
.orderBy(SALE.SALE_)
// .fetch() - optional
.forEach(System.out::println);
将结果集(SaleRecord)映射到只包含 sale 列的 List<Double> 可以通过以下方式通过 fetch().map(RecordMapper<? super R,E> rm) 完成:
ctx.selectFrom(SALE)
.orderBy(SALE.SALE_)
.fetch()
.map(SaleRecord::getSale)
.forEach(System.out::println);
或者,可以通过以下方式通过 fetch(RecordMapper<? super R,E> rm) 完成:
ctx.selectFrom(SALE)
.orderBy(SALE.SALE_)
.fetch(SaleRecord::getSale)
.forEach(System.out::println);
这也可以通过以下 Lambda 表达式完成:
ctx.selectFrom(SALE)
.orderBy(SALE.SALE_)
.fetch(s -> s.getSale())
.forEach(System.out::println);
或者,甚至可以通过以下匿名记录映射器完成:
return ctx.selectFrom(SALE)
.orderBy(SALE.SALE_)
.fetch(new RecordMapper<SaleRecord, Double>() {
@Override
public Double map(SaleRecord sr) {
return sr.getSale();
}
});
接下来,让我们看看如何使用 jOOQ 流畅 API 与 Java Stream 流畅 API 结合使用。
使用 Stream API
使用 jOOQ 流畅 API 和 Stream 流畅 API 作为显然的单个流畅 API 是直接的。让我们假设我们有一个这样的 POJO:
public class SaleStats implements Serializable {
private double totalSale;
private List<Double> sales;
// constructor, getters, setters, ... omitted for brevity
}
一个原始 SQL 可以通过以下方式获得 SaleStats 实例:
SaleStats result = ctx.fetch(
"SELECT sale FROM sale") // jOOQ fluent API ends here
.stream() // Stream fluent API starts here
.collect(Collectors.teeing(
summingDouble(rs -> rs.getValue("sale", Double.class)),
mapping(rs -> rs.getValue("sale", Double.class),
toList()), SaleStats::new));
但是,如果我们使用基于 Java 的模式,那么此代码可以重写如下:
SaleStats result = ctx.select(SALE.SALE_)
.from(SALE)
.fetch() // jOOQ fluent API ends here
.stream() // Stream fluent API starts here
.collect(Collectors.teeing(
summingDouble(rs -> rs.getValue(SALE.SALE_)),
mapping(rs -> rs.getValue(SALE.SALE_), toList()),
SaleStats::new));
看起来 jOOQ 流畅 API 和 Stream 流畅 API 配合得像魔法一样!我们只需要在fetch()之后调用stream()方法。当fetch()将整个结果集加载到内存中时,stream()在这个结果集上打开一个流。通过fetch()将整个结果集加载到内存中允许在流式传输结果集之前关闭 JDBC 资源(例如,连接)。
然而,除了stream()方法外,jOOQ 还提供了一个名为fetchStream()的方法,该方法将在本章后面讨论,专门用于懒加载以及其他特定主题。作为一个快速提示,请记住fetch().stream()和fetchStream()不是同一回事。
本节中的示例被分组在FunctionalJooq应用程序中。
流畅的编程配置
在上一章中,你已经通过程序化的流畅 API 构建了代码生成器配置的滋味。以下代码片段只是 jOOQ Settings流畅 API 的另一个示例:
List<Office> result = ctx.configuration()
.set(new Settings().withRenderSchema(Boolean.FALSE)
.withMaxRows(5)
.withInListPadding(Boolean.TRUE)).dsl()
.selectFrom(...)
...
.fetchInto(Office.class);
这些并不是 jOOQ 流畅 API 闪耀的唯一情况。例如,检查 jOOQ JavaFX 应用程序,它可以从 jOOQ result创建条形图。这在 jOOQ 手册中可用。
接下来,让我们看看 jOOQ 如何强调我们的流畅代码应该尊重 SQL 语法的正确性。
强调 jOOQ 注重 SQL 语法的正确性
jOOQ 最酷的特性之一是它不允许我们编写错误的 SQL 语法。如果你不是 SQL 专家或者只是对 SQL 特定的语法有问题,那么你所要做的就是让 jOOQ 一步步引导你。
拥有一个流畅的 API 来链式调用方法以获取 SQL 是酷的,但拥有一个强调 SQL 语法正确性的流畅 API 是最酷的。jOOQ 确切地知道查询部分如何拼凑成完整的拼图,并且将通过你的 IDE 帮助你。
例如,让我们假设我们意外地编写了以下错误的 SQL。让我们从一个缺少ON子句的 SQL 开始:
ctx.select(EMPLOYEE.JOB_TITLE,
EMPLOYEE.OFFICE_CODE, SALE.SALE_)
.from(EMPLOYEE)
.join(SALE)
// "on" clause is missing here
.fetch();
如下图中所示,IDE 立即发出此问题的信号:
![Figure 3.3 – Wrong SQL
![img/B16833_Figure_3.3.jpg]
图 3.3 – 错误的 SQL
让我们继续另一个错误的 SQL,它在不适当的位置使用了JOIN:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.union(select(CUSTOMER.CONTACT_FIRST_NAME,
CUSTOMER.CONTACT_LAST_NAME)
.from(CUSTOMER))
.join(CUSTOMER)
// "join" is not allowed here
.on(CUSTOMER.SALES_REP_EMPLOYEE_NUMBER
.eq(EMPLOYEE.EMPLOYEE_NUMBER))
.fetch();
最后,让我们看看一个缺少over()的错误的 SQL:
ctx.select(CUSTOMER.CUSTOMER_NAME,
ORDER.ORDER_DATE,lead(ORDER.ORDER_DATE, 1)
// missing over()
.orderBy(ORDER.ORDER_DATE).as("NEXT_ORDER_DATE"))
.from(ORDER)
.join(CUSTOMER)
.on(ORDER.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))
.fetch();
当然,我们可以永远这样继续下去,但我认为你已经明白了这个想法!所以,相信 jOOQ 吧!
类型转换、强制转换和比较
jOOQ 被设计用来在底层处理大多数类型转换问题,包括对于如 DB2 这样的超强类型数据库。尽管如此,显式的类型转换和/或强制转换仍然适用于一些孤立的情况。很可能会在我们对 jOOQ 自动映射不满意(例如,我们认为 jOOQ 没有找到最精确的映射)或我们需要某种类型来应对特殊情况时使用它们。即使它们增加了一点点冗余,类型转换和强制转换也可以流畅地使用;因此,DSL 表达式不会被破坏。
转换
大多数情况下,jOOQ 在数据库和 Java 之间找到最准确的数据类型映射。如果我们查看一个反映数据库表的 jOOQ 生成的类,那么我们会看到,对于每个具有数据库特定类型(例如,VARCHAR)的列,jOOQ 都找到了一个 Java 类型对应物(例如,String)。如果我们比较 PAYMENT 表的架构与生成的 jooq.generated.tables.Payment 类,那么我们会发现以下数据类型对应关系:

图 3.4 – 数据库和 Java 之间的类型映射
当 jOOQ 映射不是我们需要的,或者 jOOQ 无法推断出某种类型时,我们可以依赖 jOOQ 转换 API,它包含以下方法:
// cast this field to the type of another field
<Z> Field<Z> cast(Field<Z> field);
// cast this field to a given DataType
<Z> Field<Z> cast(DataType<Z> type);
// cast this field to the default DataType for a given Class
<Z> Field<Z> cast(Class<? extends Z> type);
除了这些方法之外,DSL 类还包含以下方法:
<T> Field<T> cast(Object object, Field<T> field);
<T> Field<T> cast(Object object, DataType<T> type);
<T> Field<T> cast(Object object, Class<? extends T> type);
<T> Field<T> castNull(Field<T> field);
<T> Field<T> castNull(DataType<T> type);
<T> Field<T> castNull(Class<? extends T> type);
让我们用 MySQL 举一些例子,并从以下查询开始,该查询将获取的数据映射到 jOOQ 自动选择的 Java 类型:
Result<Record2<BigDecimal, LocalDateTime>> result =
ctx.select(PAYMENT.INVOICE_AMOUNT.as("invoice_amount"),
PAYMENT.CACHING_DATE.as("caching_date"))
.from(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER.eq(103L))
.fetch();
因此,INVOICE_AMOUNT 映射到 BigDecimal,而 CACHING_DATE 映射到 LocalDateTime。让我们假设我们处于一个特殊情况,需要将 INVOICE_AMOUNT 作为 String 和 CACHING_DATE 作为 LocalDate 获取。当然,我们可以循环前面的结果并在 Java 中执行每个记录的转换,但在查询级别,我们可以通过 jOOQ 的 cast() 方法完成此操作,如下所示:
Result<Record2<String, LocalDate>> result =
ctx.select(
PAYMENT.INVOICE_AMOUNT.cast(String.class)
.as("invoice_amount"),
PAYMENT.CACHING_DATE.cast(LocalDate.class)
.as("caching_date"))
.from(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER.eq(103L))
.fetch();
查看使用 cast() 生成的 SQL 字符串:
SELECT
cast(`classicmodels`.`payment`.`invoice_amount` as char)
as `invoice_amount`,
cast(`classicmodels`.`payment`.`caching_date` as date)
as `caching_date`
FROM`classicmodels`.`payment`
WHERE`classicmodels`.`payment`.`customer_number` = 103
在以下图中,你可以看到这两个 SQL 返回的结果集:

图 3.5 – 转换结果
注意,jOOQ 投影操作在生成的 SQL 字符串中呈现,因此,数据库负责执行这些转换。但是,在这种情况下,我们真的需要这些笨拙的转换,还是我们实际上需要数据类型强制转换?
强制转换
数据类型强制转换类似于转换,但它们对生成的实际 SQL 查询没有影响。换句话说,数据类型强制转换在 Java 中充当不安全的转换,并且不会在 SQL 字符串中呈现。使用数据类型强制转换,我们只指示 jOOQ 假设一个数据类型是另一种数据类型,并相应地绑定它。尽可能的情况下,我们更愿意使用强制转换而不是转换。这样,我们就不必担心转换问题,也不必用不必要的转换污染生成的 SQL。API 由几个方法组成:
// coerce this field to the type of another field
<Z> Field<Z> coerce(Field<Z> field);
// coerce this field to a given DataType
<Z> Field<Z> coerce(DataType<Z> type);
// coerce this field to the default DataType for a given Class
<Z> Field<Z> coerce(Class<? Extends Z> type);
除了这些方法之外,DSL 类还包含以下方法:
<T> Field<T> coerce(Field<?> field, DataType<T> as)
<T> Field<T> coerce(Field<?> field, Field<T> as)
<T> Field<T> coerce(Field<?> field, Class<T> as)
<T> Field<T> coerce(Object value, Field<T> as)
<T> Field<T> coerce(Object value, DataType<T> as)
<T> Field<T> coerce(Object value, Field<T> as)
在 转换 部分的示例中,我们依赖于从 BigDecimal 到 String 以及从 LocalDateTime 到 LocalDate 的转换。这种转换在 SQL 字符串中呈现,并由数据库执行。但是,我们可以通过强制转换避免用这些转换污染 SQL 字符串,如下所示:
Result<Record2<String, LocalDate>> result= ctx.select(
PAYMENT.INVOICE_AMOUNT.coerce(String.class)
.as("invoice_amount"),
PAYMENT.CACHING_DATE.coerce(LocalDate.class)
.as("caching_date"))
.from(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER.eq(103L))
.fetch();
生成的结果集与使用类型转换的情况相同,但 SQL 字符串没有反映强制转换,数据库也没有执行任何类型转换操作。这更好也更安全:
SELECT
`classicmodels`.`payment`.`invoice_amount`
as `invoice_amount`,
`classicmodels`.`payment`.`caching_date`
as `caching_date`
FROM `classicmodels`.`payment`
WHERE `classicmodels`.`payment`.`customer_number` = 103
从版本 3.12 开始,jOOQ 允许将 ResultQuery<R1> 强制转换为新的 ResultQuery<R2> 类型。例如,查看以下纯 SQL:
ctx.resultQuery(
"SELECT first_name, last_name FROM employee").fetch();
此查询的结果类型为 Result<Record>,但我们可以轻松地将 fetch() 替换为 fetchInto() 以将此结果映射到生成的 Employee POJO(只有 firstName 和 lastName 字段将被填充)或只包含获取字段的自定义 POJO。但是,如何获取 Result<Record2<String, String>>?这可以通过 ResultQuery.coerce() 的一种变体来实现,如下所示:
Result<Record2<String, String>> result = ctx.resultQuery(
"SELECT first_name, last_name FROM employee")
.coerce(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.fetch();
可以通过 ResultQuery.coerce(Table<X> table) 将结果集强制转换为表。您可以在捆绑代码中找到一个示例,在 jOOQ 3.12 之前的替代方案旁边。如果在强制转换过程中,jOOQ 发现任何 Converter 或 Binding 配置,则它将应用它们(这在 第七章,类型、转换器和绑定)中有所介绍。
强制转换与类型转换
不要认为 coerce() 总是可以替换 cast()。查看以下使用 coerce() 的示例:
Result<Record2<BigDecimal, String>> result = ctx.select(
PRODUCT.BUY_PRICE.coerce(SQLDataType.DECIMAL(10, 5))
.as("buy_price"),
PRODUCT.PRODUCT_DESCRIPTION.coerce(SQLDataType.VARCHAR(10))
.as("prod_desc"))
.from(PRODUCT)
.where(PRODUCT.PRODUCT_ID.eq(1L))
.fetch();
因此,我们假装 BUY_PRICE 是具有 10 位精度和 5 位刻度的 BigDecimal,而 PRODUCT_DESCRIPTION 是长度为 10 的字符串。但是,强制转换无法做到这一点。在这种情况下,强制转换可以假装 BigDecimal(BUY_PRICE 实际上被当作 BigDecimal 值处理),以及 String(PRODUCT_DESCRIPTION 实际上被当作 String 值处理)类型,但它不能假装域约束。
让我们将 coerce() 替换为 cast():
Result<Record2<BigDecimal, String>> result = ctx.select(
PRODUCT.BUY_PRICE.cast(SQLDataType.DECIMAL(10, 5))
.as("buy_price"),
PRODUCT.PRODUCT_DESCRIPTION.cast(SQLDataType.VARCHAR(10))
.as("prod_desc"))
.from(PRODUCT)
.where(PRODUCT.PRODUCT_ID.eq(1L))
.fetch();
这次,类型转换在生成的 SQL 字符串中得到了体现。以下图比较了使用 coerce() 和 cast() 的结果;这正如预期的那样工作:

图 3.6 – 强制转换与类型转换(1)
让我们再看一个示例。查看以下使用 coerce() 的示例:
public void printInvoicesPerDayCoerce(LocalDate day) {
ctx.select(PAYMENT.INVOICE_AMOUNT)
.from(PAYMENT)
.where(PAYMENT.PAYMENT_DATE
.coerce(LocalDate.class).eq(day))
.fetch()
.forEach(System.out::println);
}
PAYMENT.PAYMENT_DATE 是一个时间戳,因此,仅仅假装它是日期是不够的,因为时间部分将使我们的谓词失败。例如,2003-04-09 09:21:25 不等于 2003-04-09。在这种情况下,我们需要将时间戳实际转换为日期,如下所示:
public void printInvoicesPerDayCast(LocalDate day) {
ctx.select(PAYMENT.INVOICE_AMOUNT)
.from(PAYMENT)
.where(PAYMENT.PAYMENT_DATE
.cast(LocalDate.class).eq(day))
.fetch()
.forEach(System.out::println);
}
这次,转换是通过以下 SQL(对于 2003-04-09)进行的:
SELECT `classicmodels`.`payment`.`invoice_amount`
FROM `classicmodels`.`payment`
WHERE cast(`classicmodels`.`payment`.`payment_date` as date)
= { d '2003-04-09' }
以下图比较了使用 coerce() 和 cast() 的结果:

图 3.7 – 强制转换与类型转换(2)
另一个 cast 起作用而 coerce 不起作用的良好例子是在 GROUP BY 中执行 cast,这在按 CAST(ts AS DATE) 对时间戳列进行分组时并不罕见。此外,当被 cast 的值是表达式而不是绑定变量时,效果不同(尽管可以使用 coerce 来比较,例如,INTEGER 列与 BIGINT 列,而无需数据库进行任何转换)。
重要提示
作为经验法则,在某些情况下,当两者都可以工作(例如,当你投影表达式时),最好使用 coerce() 而不是 cast()。这样,你就不必担心在 Java 中进行不安全或原始类型转换的风险,也不会在生成的 SQL 中引入不必要的转换。
接下来,让我们讨论校对规则。
校对规则
数据库将字符集定义为符号和编码的集合。校对规则定义了在字符集中比较(排序)字符的规则。jOOQ 允许我们通过 collation(校对规则)为 org.jooq.DateType 指定校对规则,以及通过 collate(字符串校对规则)、collate(校对规则)和 collate(名称校对规则)为 org.jooq.Field 指定校对规则。以下是一个为字段设置 latin1_spanish_ci 校对规则的示例:
ctx.select(PRODUCT.PRODUCT_NAME)
.from(PRODUCT)
.orderBy(PRODUCT.PRODUCT_NAME.collate("latin1_spanish_ci"))
.fetch()
.forEach(System.out::println);
本节中的所有示例都可在 CastCoerceCollate 应用程序中找到。
绑定值(参数)
绑定值是 jOOQ 的另一个基本主题。
众所周知,在 JDBC 中表达 SQL 语句时,使用预定义语句和绑定值的组合是首选方法。这种组合的好处包括提供对 SQL 注入的保护、支持缓存(例如,大多数连接池会在连接之间缓存预定义语句,或者像 HikariCP 一样依赖 JDBC 驱动程序的缓存功能),以及可重用性能力(对于相同的 SQL 语句,无论实际的绑定值如何,都可以重用执行计划)。
将安全和性能打包在这个组合中使其比静态语句(java.sql.Statement)和内联值更可取,因此 jOOQ 也将其作为默认选项。
重要提示
默认情况下,jOOQ 将其对绑定值的支持与 JDBC 风格对齐。换句话说,jOOQ 依赖于 java.sql.PreparedStatement 和索引绑定值或索引参数。此外,与 JDBC 一样,jOOQ 使用 ?(问号)字符来标记绑定值占位符。
然而,与只支持索引参数和 ? 字符的 JDBC 相比,jOOQ 还支持命名和内联参数。每个参数的详细信息都在本节中介绍。
因此,在 JDBC 中,利用绑定值的唯一方法是以下示例:
Connection conn = ...;
try (PreparedStatement stmt = conn.prepareStatement(
"""SELECT first_name, last_name FROM employee
WHERE salary > ? AND job_title = ?""")) {
stmt.setInt(1, 5000);
stmt.setString(2, "Sales Rep");
stmt.executeQuery();
}
换句话说,在 JDBC 中,跟踪问号的数量及其对应索引是我们的责任。在复杂/动态查询中,这会变得很繁琐。
正如 Lukas Eder 强调的,“L/SQL、PL/pgSQL、T-SQL(以及其他一些语言)的强大之处在于,预处理语句可以自然地透明地嵌入绑定值,而无需用户考虑绑定逻辑。”
现在,让我们看看 jOOQ 是如何通过索引绑定值或索引参数来处理绑定值的。
索引参数
通过 jOOQ 的 DSL API 编写之前的查询可以这样做:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(5000)
.and(EMPLOYEE.JOB_TITLE.eq("Sales Rep")))
.fetch();
即使看起来我们内联了值(5000 和 Sales Rep),这并不是真的。jOOQ 抽象掉了 JDBC 摩擦,并允许我们在需要的地方(直接在 SQL 中)使用索引参数。由于 jOOQ 负责一切,我们甚至不需要关心参数的索引。此外,我们利用这些参数的类型安全性,并且不需要显式设置它们的类型。前面的 SQL 生成了以下 SQL 字符串(注意渲染的问号作为绑定值占位符):
SELECT
`classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`
FROM`classicmodels`.`employee`
WHERE (`classicmodels`.`employee`.`salary` > ?
and `classicmodels`.`employee`.`job_title` = ?)
然后,在 jOOQ 解析了绑定值之后,我们得到以下结果:
SELECT
`classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`
FROM `classicmodels`.`employee`
WHERE(`classicmodels`.`employee`.`salary` > 5000
and `classicmodels`.`employee`.`job_title` = 'Sales Rep')
在幕后,jOOQ 使用一个名为DSL.val(value)的方法来将给定的value参数(value可以是boolean、byte、String、float、double等)转换为绑定值。这个DSL.val()方法通过org.jooq.Param接口包装并返回一个绑定值。这个接口扩展了org.jooq.Field,因此扩展了一个列表达式(或字段)并且可以通过 jOOQ API 相应地使用。之前的查询也可以通过显式使用DSL.val()来编写,如下所示:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(val(5000))
.and(EMPLOYEE.JOB_TITLE.eq(val("Sales Rep"))))
.fetch();
但是,正如你刚才看到的,在这种情况下显式使用val()是不必要的。这样使用val()只是在 SQL 表达式中添加噪音。
在这个查询中,我们使用了硬编码的值,但,很可能是这些值代表了通过包含此查询的方法的参数进入查询的用户输入。查看以下示例,它将这些硬编码的值作为方法的参数提取出来:
public void userInputValuesAsIndexedParams(
int salary, String job) {
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(salary)
.and(EMPLOYEE.JOB_TITLE.eq(job)))
.fetch();
}
当然,在同一个查询中混合硬编码和用户输入值也是支持的。接下来,让我们处理一些确实需要显式使用val()的例子。
显式使用val()
有时候我们不能将普通值传递给 jOOQ 并期望返回绑定值。有一些这样的情况:
-
当绑定值位于运算符的左侧时
-
当
Field引用和Param值混合时 -
当绑定值出现在不支持它的子句中时(例如,在
select()中) -
当函数需要一个
Field<T>类型作为其中一个参数时
让我们来看一些例子。
绑定值位于运算符的左侧
在操作符的左侧有普通值不允许我们编写所需的 jOOQ 表达式,因为我们没有访问 jOOQ DSL API。例如,我们不能编写 ...5000.eq(EMPLOYEE.SALARY),因为 eq() 方法不可用。另一方面,我们应该编写 ...val(5000).eq(EMPLOYEE.SALARY)。这次,5000 通过 val(int/Integer value) 被包装在 Param(它扩展了 Field)中,我们可以继续利用 jOOQ DSL API,例如 eq() 方法。以下是一个另一个示例:
ctx.select(PAYMENT.INVOICE_AMOUNT)
.from(PAYMENT)
.where(val(LocalDateTime.now())
.between(PAYMENT.PAYMENT_DATE)
.and(PAYMENT.CACHING_DATE))
.fetch();
这里有一个值是用户输入的示例:
public void usingValExplicitly(LocalDateTime date) {
ctx.select(PAYMENT.INVOICE_AMOUNT)
.from(PAYMENT)
.where(val(date).between(PAYMENT.PAYMENT_DATE)
.and(PAYMENT.CACHING_DATE))
.fetch();
}
接下来,让我们看看另一个案例,当 Field 引用和 Param 值混合时。
混合 Field 引用和 Param 值
让我们考虑我们想要使用 DSL.concat(Field<?>... fields) 方法来连接 CUSTOMER.CONTACT_FIRST_NAME、空格文本(" ")和 CUSTOMER.CONTACT_LAST_NAME(例如,Joana Nimar)。虽然 CONTACT_FIRST_NAME 和 CONTACT_LAST_NAME 是字段,但空格文本(" ")在这个上下文中不能作为一个普通字符串使用。但是,它可以通过 val() 方法被包装,如下所示:
ctx.select(CUSTOMER.CUSTOMER_NUMBER,
concat(CUSTOMER.CONTACT_FIRST_NAME, val(" "),
CUSTOMER.CONTACT_LAST_NAME))
.from(CUSTOMER)
.fetch();
这里还有一个示例,它混合了 jOOQ 内部对 val() 的使用和我们对将用户输入值包装为结果集中列的显式 val() 使用:
public void usingValExplicitly(float vat) {
ctx.select(
EMPLOYEE.SALARY,
// jOOQ implicit val()
EMPLOYEE.SALARY.mul(vat).as("vat_salary"),
// explicit val()
val(vat).as("vat"))
.from(EMPLOYEE)
.fetch();
}
这里是另一个混合隐式和显式 val() 使用来编写简单算术表达式的示例,mod((((10 - 2) * (7 / 3)) / 2), 10):
ctx.select(val(10).sub(2).mul(val(7).div(3)).div(2).mod(10))
.fetch();
当同一个参数被多次使用时,建议像以下示例中那样提取它:
public void reusingVal(int salary) {
Param<Integer> salaryParam = val(salary);
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
salaryParam.as("base_salary"))
.from(EMPLOYEE)
.where(salaryParam.eq(EMPLOYEE.SALARY))
.and(salaryParam.mul(0.15).gt(10000))
.fetch();
}
当我们关注 salary 值时,jOOQ 将处理 0.15 和 10000 常量。所有三个都将成为索引绑定值。
来自字符串查询的绑定值
如果出于某种原因,您想直接从字符串查询中绑定值,那么您可以通过以下示例中的普通 SQL 来实现:
// bind value from string query
ctx.fetch("""
SELECT first_name, last_name
FROM employee WHERE salary > ? AND job_title = ?
""", 5000, "Sales Rep");
// bind value from string query
ctx.resultQuery("""
SELECT first_name, last_name
FROM employee WHERE salary > ? AND job_title = ?
""", 5000, "Sales Rep")
.fetch();
接下来,让我们谈谈命名参数。
命名参数
虽然 JDBC 的支持仅限于索引绑定值,但 jOOQ 超越了这个限制,同时也支持命名参数。创建一个 jOOQ 命名参数是通过 DSL.param() 方法实现的。在这些方法中,我们有 param(String name, T value),它创建一个具有名称和初始值的命名参数。以下是一个示例:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(param("employeeSalary", 5000))
.and(EMPLOYEE.JOB_TITLE
.eq(param("employeeJobTitle", "Sales Rep"))))
.fetch();
这里是一个命名参数值作为用户输入提供的示例:
public void userInputValuesAsNamedParams(
int salary, String job) {
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY
.gt(param("employeeSalary", salary))
.and(EMPLOYEE.JOB_TITLE
.eq(param("employeeJobTitle", job))))
.fetch();
}
在渲染之前查询的 SQL 时,您已经观察到 jOOQ 并没有将这些参数的名称作为占位符渲染。它仍然使用问号作为默认占位符。为了指示 jOOQ 将参数的名称作为占位符渲染,我们通过 DSL.renderNamedParams() 方法调用,该方法返回一个字符串,如下面的示例所示:
String sql = ctx.renderNamedParams(
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(param("employeeSalary", 5000))
.and(EMPLOYEE.JOB_TITLE
.eq(param("employeeJobTitle", "Sales Rep"))))
此外,我们可以通过 Settings.withRenderNamedParamPrefix() 指定一个字符串作为每个渲染的命名参数的前缀。您可以在捆绑的代码中看到一个示例。
返回的字符串可以传递给支持命名参数的另一个 SQL 访问抽象。例如,渲染的 SQL 字符串如下:
SELECT
`classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`
FROM `classicmodels`.`employee`
WHERE (`classicmodels`.`employee`.`salary` > : employeeSalary
and `classicmodels`.`employee`.`job_title`
= : employeeJobTitle)
接下来,让我们谈谈内联参数。
内联参数
内联绑定值通过 DSL.inline() 被渲染为实际的 plain 值。换句话说,虽然索引和命名参数通过问号(或名称)将绑定值作为占位符渲染,但内联参数直接渲染它们的 plain 值。jOOQ 自动替换占位符(对于命名参数是 ? 或 :name),并将内联绑定值正确转义以避免 SQL 语法错误和 SQL 注入。尽管如此,请注意,过度使用内联参数可能会导致在具有执行计划缓存的 RDBMS 上性能下降。因此,请避免在所有地方复制和粘贴 inline()!
通常,对于常量使用 inline() 是一个好习惯。例如,之前,我们使用 val(" ") 来表示 concat(CUSTOMER.CONTACT_FIRST_NAME, val(" "), CUSTOMER.CONTACT_LAST_NAME))。但是,由于 " " 字符串是常量,它可以被内联:
ctx.select(CUSTOMER.CUSTOMER_NUMBER,
concat(CUSTOMER.CONTACT_FIRST_NAME, inline(" "),
CUSTOMER.CONTACT_LAST_NAME))
.from(CUSTOMER)
.fetch();
但是,如果您知道这不是一个常量,那么最好依赖于 val() 以维持执行计划缓存。
在 Configuration 层级,我们可以通过从 PreparedStatement 默认设置切换到静态 Statement 通过 jOOQ 设置来使用内联参数。例如,以下 DSLContext 将使用静态语句,并且在此配置的上下文中触发的所有查询都将使用内联参数:
public void inlineParamsViaSettings() {
DSL.using(ds, SQLDialect.MYSQL,
new Settings().withStatementType(
StatementType.STATIC_STATEMENT))
.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(5000)
.and(EMPLOYEE.JOB_TITLE.eq("Sales Rep")))
.fetch();
}
显然,另一个选项是依赖于 inline():
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(inline(5000))
.and(EMPLOYEE.JOB_TITLE.eq(inline("Sales Rep"))))
.fetch();
当然,内联值也可以是用户输入。但是,由于用户输入可能在执行过程中变化,这会影响依赖于执行计划缓存的 RDBMS 的性能。因此,不建议使用此技术。
前两个示例渲染了相同的 SQL,其中实际 plain 值被内联:
SELECT
`classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`
FORM `classicmodels`.`employee`
WHERE (`classicmodels`.`employee`.`salary` > 5000
and `classicmodels`.`employee`.`job_title` = 'Sales Rep')
在全局范围内,我们可以通过 Settings 选择参数的类型,如下所示(默认使用索引参数 ParamType.INDEXED):
@Bean
public Settings jooqSettings() {
return new Settings().withParamType(ParamType.NAMED);
}
或者,这里是对使用静态语句和内联参数的全局设置:
@Bean
public Settings jooqSettings() {
return new Settings()
.withStatementType(StatementType.STATIC_STATEMENT)
.withParamType(ParamType.INLINED);
}
接下来,让我们看看一种方便的方法来渲染具有不同类型参数占位符的查询。
渲染具有不同类型参数占位符的查询
假设我们有一个使用索引参数的查询,并且我们需要将其渲染为具有不同类型参数占位符的特定 SQL 字符串(例如,这可能是另一个 SQL 抽象所必需的):
ResultQuery query
= ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(5000)
.and(EMPLOYEE.JOB_TITLE.eq("Sales Rep")));
使用不同类型参数占位符渲染此查询的便捷方法依赖于 Query.getSQL(ParamType) 方法,如下所示:
ParamType.INDEXED(在这个例子中,这是默认行为):
String sql = query.getSQL(ParamType.INDEXED);
SELECT
`classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`
FROM `classicmodels`.`employee`
WHERE (`classicmodels`.`employee`.`salary` > ?
and `classicmodels`.`employee`.`job_title` = ?)
ParamType.NAMED(对于有名称的参数,这会产生:name类型的占位符,但对于无名称的参数,则产生:1、:2到:n,因此,是冒号和索引的组合):
String sql = query.getSQL(ParamType.NAMED);
SELECT
`classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`
FROM `classicmodels`.`employee`
WHERE (`classicmodels`.`employee`.`salary` > :1
and `classicmodels`.`employee`.`job_title` = :2)
ParamType.INLINED和ParamType.NAMED_OR_INLINED:
String sql = query.getSQL(ParamType.INLINED);
String sql = query.getSQL(ParamType.NAMED_OR_INLINED);
SELECT
`classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`
FROM `classicmodels`.`employee`
WHERE (`classicmodels`.`employee`.`salary` > 5000 and
`classicmodels`.`employee`.`job_title` = 'Sales Rep')
在这种情况下,ParamType.INLINED 和 ParamType.NAMED_OR_INLINED 产生相同的输出 - 内联普通值。实际上,ParamType.NAMED_OR_INLINED 只为显式命名的参数生成命名参数占位符,否则,它将内联所有未命名的参数。您可以在本书附带代码中看到更多示例。
接下来,让我们看看如何将 jOOQ 参数从查询中提取为 List<Object>。
从查询中提取 jOOQ 参数
通过 Query.getParams() 可以访问查询的所有支持类型参数,而通过索引访问单个参数可以通过 Query.getParam() 完成,如下例所示,它使用了索引参数(相同的方法也可以用于内联参数):
ResultQuery query
= ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(5000))
.and(EMPLOYEE.JOB_TITLE.eq("Sales Rep"));
// wrap the value, 5000
Param<?> p1 = query.getParam("1");
// wrap the value, "Sales Rep"
Param<?> p2 = query.getParam("2");
如果我们使用命名参数,则可以使用这些名称代替索引:
ResultQuery query
= ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(
param("employeeSalary", 5000)))
.and(EMPLOYEE.JOB_TITLE.eq(
param("employeeJobTitle", "Sales Rep")));
// wrap the value, 5000
Param<?> p1 = query.getParam("employeeSalary");
// wrap the value, "Sales Rep"
Param<?> p2 = query.getParam("employeeJobTitle");
如您很快就会看到的,参数可以用来设置新的绑定值。接下来,让我们看看如何提取索引和命名参数。
提取绑定值
拥有一个参数,我们可以通过 getValue() 提取其底层的绑定值。
但是,在不与 Param 交互的情况下,可以通过 getBindValues() 提取索引和命名参数的所有查询绑定值。此方法返回 List<Object>,其中包含查询的所有绑定值,表示为查询或其任何子接口,如 ResultQuery、Select 等。以下是一个索引参数的示例:
public void extractBindValuesIndexedParams() {
ResultQuery query
= ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(5000))
.and(EMPLOYEE.JOB_TITLE.eq("Sales Rep"));
System.out.println("Bind values: "
+ query.getBindValues());
}
此外,以下是一个命名参数的示例:
public void extractBindValuesNamedParams() {
ResultQuery query = ctx.select(
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(param("employeeSalary", 5000))
.and(EMPLOYEE.JOB_TITLE
.eq(param("employeeJobTitle", "Sales Rep"))));
System.out.println("Bind values: "
+ query.getBindValues());
}
在这两个示例中,返回的列表将包含两个绑定值,[5000 和 销售代表]。对于内联参数,getBindValues() 返回一个空列表。这是因为,与返回所有支持类型的参数的 getParams() 不同,getBindValues() 只返回实际渲染实际占位符的实际绑定值。
我们可以使用提取的绑定值在另一个 SQL 抽象中,例如 JdbcTemplate 或 JPA。例如,以下是一个 JdbcTemplate 的示例:
Query query = ctx.select(...)
.from(PAYMENT)
.where(...);
List<DelayedPayment> result = jdbcTemplate.query(query.getSQL(),
query.getBindValues().toArray(), new BeanPropertyRowMapper
(DelayedPayment.class));
设置新的绑定值
我们必须以以下重要注意事项开始本节。
重要注意事项
根据 jOOQ 文档,从版本 4.0 开始,jOOQ 计划使 Param 类不可变。强烈不建议修改 Param 值;因此,请仔细使用本节中的信息。
尽管如此,在本书编写时,通过 Param 修改绑定值仍然是可能的。例如,以下示例执行了一个带有初始绑定值的 SQL 语句,设置新的绑定值,然后再次执行查询。设置新的绑定值是通过已弃用的 setConverted() 方法完成的:
public void modifyingTheBindValueIndexedParam() {
try ( ResultQuery query
= ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(5000))
.and(EMPLOYEE.JOB_TITLE.eq("SalesRep"))
.keepStatement(true)) {
// lazily create a new PreparedStatement
Result result1 = query.fetch();
System.out.println("Result 1: " + result1);
// set new bind values
Param<?> p1 = query.getParam("1");
Param<?> p2 = query.getParam("2");
p1.setConverted(75000);
p2.setConverted("VP Marketing");
// re-use the previous PreparedStatement
Result result2 = query.fetch();
System.out.println("Result 2: " + result2);
}
}
Query 接口还允许直接设置新的绑定值,而无需通过 bind() 方法显式访问 Param 类型,如下所示(如果有通过名称而不是索引引用它们的命名参数):
public void modifyingTheBindValueIndexedParam() {
try ( ResultQuery query
= ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.gt(5000))
.and(EMPLOYEE.JOB_TITLE.eq("Sales Rep"))
.keepStatement(true)) {
// lazily create a new PreparedStatement
Result result1 = query.fetch();
System.out.println("Result 1: " + result1);
// set new bind values
query.bind(1, 75000);
query.bind(2, "VP Marketing");
// re-use the previous PreparedStatement
Result result2 = query.fetch();
System.out.println("Result 2: " + result2);
}
}
然而,在幕后,bind() 通过 Param.setConverted() 工作。
为了方便(但不是必需的),请注意,这两个示例都利用了PreparedStatement可以与不同的绑定值重用的特性。首先,我们通过keepStatement(true)请求 jOOQ 保持语句打开。其次,Query变成了必须通过Query.close()或try-with-resources语句关闭的资源。
在内联参数的情况下,jOOQ 会自动关闭任何底层的PreparedStatement,以便新的绑定值能够生效;因此,保持语句打开是没有必要的。代码简单直接,可以在本书附带代码中找到。
无初始值的命名/无名称参数
在前面的示例中,参数具有后来修改的初始值,而 jOOQ 也支持没有初始值的命名/无名称参数。
如果你需要在创建时没有提供初始值的命名参数,那么你可能需要以下DSL.param()之一。
下面是一个使用DSL.param(String name)返回Param<Object>的示例:
Param<Object> phoneParam = DSL.param("phone");
// set the parameter value
phoneParam.setValue("(26) 642-7555");
ctx.selectFrom(CUSTOMER)
.where(phoneParam.eq(CUSTOMER.PHONE))
.fetch();
这是一个通过param(String name, Class<T> type)创建具有定义类类型但没有初始值的命名参数的示例:
Param<String> phoneParam = DSL.param("phone", String.class);
phoneParam.setValue("(26) 642-7555");
这是我们通过param(String name, DataType<T> type)创建具有定义数据类型但没有初始值的命名参数的方式:
Param<String> phoneParam
= DSL.param("phone", SQLDataType.VARCHAR);
phoneParam.setValue("(26) 642-7555");
此外,我们可以通过param(String name, Field<T> type)创建具有定义类型的其他字段但没有初始值的命名参数:
Param<String> phoneParam = DSL.param("phone", CUSTOMER.PHONE);
phoneParam.setValue("(26) 642-7555");
我们还可以保留一个具有初始值的命名参数的引用(例如,仅为了不丢失泛型类型<T>):
Param<String> phoneParam
= DSL.param("phone", "(26) 642-7555");
// changing the value is still possible
phoneParam.setValue("another_value");
此外,jOOQ 支持没有初始值但有定义类型的无名称参数。我们可以通过param(Class<T> class)、param(DataType<T> dataType)和param(Field<T> field)创建此类参数。
此外,我们可以通过param()(Object/SQLDataType.OTHER)创建没有名称和初始值的泛型类型参数。你可以在本书附带代码中找到示例。
通过Query使用renderNamedParams()渲染无名称参数会导致参数位置从 1 开始渲染,例如:1、:2到:n。
重要提示
在撰写本文时,jOOQ 仍然支持修改绑定值,但setValue()和setConverted()已被弃用,并且可能在 4.0 版本中被删除,届时 jOOQ 计划使Param不可变。
还要注意param()和param(String name)。一般来说,如果你使用以下方言中的任何一个:SQLDialect.DB2、DERBY、H2、HSQLDB、INGRES 和 SYBASE,应避免使用这些方法。这些方言可能难以推断绑定值的类型。在这种情况下,应优先使用显式设置绑定值类型的param()变体。
本节的所有示例都可在BindingParameters应用程序中找到。
摘要
这是一个全面的章节,涵盖了 jOOQ 的几个基本方面。到目前为止,你已经学习了如何创建DSLContext,jOOQ 流畅 API 是如何工作的,如何处理 jOOQ 的Result和Record,如何处理类型转换和强制转换的边缘情况,以及如何使用绑定值。一般来说,掌握这些基础知识将为你带来重大优势,帮助你做出正确和最优的决定,并在下一章中提供极大的支持。
在下一章中,我们将讨论构建 DAO 层和/或演进 jOOQ 生成的 DAO 层的替代方案。
第四章:构建 DAO 层(演进生成的 DAO 层)
到目前为止,我们知道如何启用 jOOQ 代码生成器,以及如何通过 jOOQ DSL API 表达查询,并且我们对 jOOQ 的工作原理有了相当的了解。换句话说,我们知道如何启动和配置一个依赖 jOOQ 进行持久层实现的 Spring Boot 应用程序。
在本章中,我们探讨了在 数据访问对象 (DAO)层中组织查询的不同方法。作为一个 Spring Boot 粉丝,你很可能熟悉以仓库为中心的 DAO 层,因此,你会看到 jOOQ 如何适应这个环境。到本章结束时,你将熟悉以下内容:
-
连接到 DAO 层
-
构建 DAO 设计模式并使用 jOOQ
-
构建通用的 DAO 设计模式并使用 jOOQ
-
扩展 jOOQ 内置 DAO
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter04。
连接到 DAO 层
DAO 是一个代表 数据访问对象 的设计模式。遵循逻辑分离原则,DAO 将数据持久化逻辑分离到一个专用层,并抽象出低级数据库操作。通常,DAO 围绕三个主要组件进行设计:
-
一个表示层与层之间传输的数据的模型(例如,
Sale模型对应于SALE数据库表) -
一个包含应实现模型 API 的接口(例如,
SaleDao,或者在 Spring 术语中,SaleRepository) -
该接口的具体实现(例如,
SaleDaoImpl,或者在 Spring 术语中,SaleRepositoryImpl)
以下图展示了使用 Sale、SaleRepository 和 SaleRepositoryImpl 之间的关系:


图 4.1 – DAO 设计模式
如果你是一个 JdbcTemplate 粉丝,你很可能在自己的应用程序中认出这个模式。另一方面,如果你熟悉 Spring Data JPA/JDBC,那么你可以将 Sale 关联到 JPA/JDBC 实体,将 SaleRepository 关联到 Spring 仓库的扩展(例如,CrudRepository 或 JpaRepository),将 SaleRepositoryImpl 关联到为 SaleRepository 自动创建的 Spring 代理实例。
这种设计模式的一种变体被称为 通用 DAO。在这种情况下,目标是隔离所有仓库共有的查询方法(例如,fetchAll()、fetchById()、insert()、update() 等等)与特定于仓库的查询方法(例如,findSaleByFiscalYear())。这次,我们在一个通用接口(例如,ClassicModelsRepository<>)中添加了常用方法,并为它提供了实现(ClassicModelsRepositoryImpl<>)。
以下图表示例描绘了使用相同的 Sale、SaleRepository 和 SaleRepositoryImpl 的两种泛型 DAO 经典风味:
![图 4.2 – 泛型 DAO (1)
![图片 B16833_Figure_4.2.jpg]
图 4.2 – 泛型 DAO (1)
在 图 4.2 中,SaleRepository 的实现必须提供一个通用 ClassicModelsRepository 的实现。每个存储库都将遵循此技术。为了增加 DAO 层的灵活性,我们添加了一个通用接口的单独实现,如下图所示:
![图 4.3 – 泛型 DAO (2)
![图片 B16833_Figure_4.3.jpg]
图 4.3 – 泛型 DAO (2)
如果您熟悉 Spring Data JPA/JDBC,那么您可以将 ClassicModelsRepository 与 Spring 内置的存储库(例如,CrudRepository 或 JpaRepository)以及此接口的实现 ClassicModelsRepositoryImpl 与 Spring 内置的实现(例如,SimpleJpaRepository)相关联。
接下来,让我们看看我们如何塑造这些 DAO 模式并使用 jOOQ。
塑造 DAO 设计模式和使用 jOOQ
假设我们有一系列针对 SALE 表的 jOOQ SQL 语句,我们想要围绕它们构建一个简单的 DAO 实现。这相当简单,因为我们只需遵循上一节中的 图 4.1。
首先,模型由 jOOQ 生成器以 POJO 的形式提供(我们也可以有用户定义的 POJO),因此,我们已经有 Sale POJO。接下来,我们编写 SaleRepository:
@Repository
@Transactional(readOnly=true)
public interface SaleRepository {
public List<Sale> findSaleByFiscalYear(int year);
public List<Sale> findSaleAscGtLimit(double limit);
}
SaleRepositoryImpl 为这两个方法提供了 jOOQ 实现:
@Repository
public class SaleRepositoryImpl implements SaleRepository {
private final DSLContext ctx;
public SaleRepositoryImpl(DSLContext ctx) {
this.ctx = ctx;
}
@Override
public List<Sale> findSaleByFiscalYear(int year) {
return ctx...
}
@Override
public List<Sale> findSaleAscGtLimit(double limit) {
return ctx...;
}
}
完成!接下来,我们可以简单地注入 SaleRepository 并调用查询方法:
@Service
public class SalesManagementService {
private final SaleRepository saleRepository;
public SalesManagementService(
SaleRepository saleRepository) {
this.saleRepository = saleRepository;
}
public List<Sale> fetchSaleByFiscalYear(int year) {
return saleRepository.findSaleByFiscalYear(year);
}
public List<Sale> fetchSaleAscGtLimit(double limit) {
return saleRepository.findSaleAscGtLimit(limit);
}
}
以同样的方式,我们可以通过添加更多存储库和其他模型实现来演进这个 DAO 层。此应用程序以 SimpleDao 的形式适用于 Maven 和 Gradle。
此外,如果您必须将 Spring Data JPA DAO 与用户定义的 jOOQ DAO 结合在一个接口中,那么只需简单地扩展所需的接口,如下例所示:
@Repository
@Transactional(readOnly = true)
public interface SaleRepository
extends JpaRepository<Sale, Long>, // JPA
com.classicmodels.jooq.repository.SaleRepository { // jOOQ
List<Sale> findTop10By(); // Sale is a JPA entity
}
一旦注入 SaleRepository,您将能够访问 Spring Data JPA DAO 和用户定义的 jOOQ DAO,在同一服务中。此示例命名为 JpaSimpleDao。
塑造泛型 DAO 设计模式和使用 jOOQ
尝试从 图 4.2 实现泛型 DAO 以通用接口 ClassicModelsRepository 为起点:
@Repository
@Transactional(readOnly = true)
public interface ClassicModelsRepository<T, ID> {
List<T> fetchAll();
@Transactional
void deleteById(ID id);
}
虽然 ClassicModelsRepository 包含了常见的查询方法,但 SaleRepository 通过添加特定的查询方法对其进行扩展,如下所示:
@Repository
@Transactional(readOnly = true)
public interface SaleRepository
extends ClassicModelsRepository<Sale, Long> {
public List<Sale> findSaleByFiscalYear(int year);
public List<Sale> findSaleAscGtLimit(double limit);
}
SaleRepository 的实现提供了两个接口的方法实现:
@Repository
public class SaleRepositoryImpl implements SaleRepository {
private final DSLContext ctx;
public SaleRepositoryImpl(DSLContext ctx) {
this.ctx = ctx;
}
@Override
public List<Sale> findSaleByFiscalYear(int year) { ... }
@Override
public List<Sale> findSaleAscGtLimit(double limit) { ... }
@Override
public List<Sale> fetchAll() { ... }
@Override
public void deleteById(Long id) { ... }
}
完整的示例命名为 SimpleGenericDao。此外,如果您必须将 Spring Data JPA DAO 与用户定义的 jOOQ 泛型 DAO 结合在一个接口中,那么就像在 JPASimpleGenericDao 中那样扩展所需的接口。一旦注入 SaleRepository,您将能够访问 Spring Data JPA DAO 和用户定义的 jOOQ 泛型 DAO,在同一服务中。
关于实现图 4.3中的通用 DAO,怎么样?这更加灵活,但并不容易实现。由于通用性方面,我们无法直接引用表和字段,就像在之前的例子中那样。ClassicModelsRepository中的查询方法是通用的,因此,通过ClassicModelsRepositoryImpl中的 DSL 支持编写的 jOOQ 查询也必须以通用方式编写。
直观地表达 jOOQ SQL 的通用方式并不简单,但你在研究了 jOOQ 内置DAO接口和DAOImpl类的源代码之后可以做到。对于那些想要深入研究这种方法的人,可以考虑名为GenericDao的示例。如果你还想涉及 Spring Data JPA,那么可以查看JpaGenericDao。
但是,正如你在第二章中看到的,“自定义 jOOQ 的参与级别”,jOOQ 可以代表我们生成 DAO 层。让我们扩展它,并按我们的喜好丰富/自定义它。
扩展 jOOQ 内置 DAO
假设你已经配置了 jOOQ 生成器,使其在jooq.generated.tables.daos包中输出生成的 DAO 层。虽然生成的 DAO 公开了常见的查询方法,如insert()、update()、delete()以及一些fetchBy...()或fetchRange...()类型的特定查询,但我们希望扩展它,添加我们自己的查询方法。
重要提示
这是我最喜欢的在 Spring Boot 和 jOOQ 应用程序中编写 DAO 层的方式之一。
jOOQ DAO 层包含一组生成的类,这些类反映了数据库表并扩展了内置的org.jooq.impl.DAOImpl类。例如,jooq.generated.tables.daos.SaleRepository类(或者,如果你保留了 jOOQ 使用的默认命名策略,则为jooq.generated.tables.daos.SaleDao)对应于SALE表。为了扩展SaleRepository,我们必须快速查看其源代码,并突出以下部分:
@Repository
public class SaleRepository extends DAOImpl<SaleRecord,
jooq.generated.tables.pojos.Sale, Long> {
...
@Autowired
public SaleRepository(Configuration configuration) {
super(Sale.SALE, jooq.generated.tables.pojos.Sale.class,
configuration);
}
...
}
突出的代码代表了扩展SaleRepository的高潮。当我们扩展SaleRepository(或任何其他 jOOQ DAO 类)时,我们有责任传递一个有效的 jOOQ 配置,否则代码将产生NullPointerException。这是一个简单的任务,可以通过以下代码片段完成(基本上,我们将DSLContext的配置传递给SaleRepository,该配置由 Spring Boot 准备):
@Repository
@Transactional(readOnly = true)
public class SaleRepositoryImpl extends SaleRepository {
private final DSLContext ctx;
public SaleRepositoryImpl(DSLContext ctx) {
super(ctx.configuration());
this.ctx = ctx;
}
...
}
那就是全部了!现在,你可以利用SaleRepositoryImpl和SaleRepository中定义的查询方法。换句话说,你可以将 jOOQ 内置的 DAO 和你的 DAO 作为一个“单一”的 DAO 来使用。以下是一个示例:
@Service
public class SalesManagementService {
private final SaleRepositoryImpl saleRepository;
public SalesManagementService(
SaleRepositoryImpl saleRepository) {
this.saleRepository = saleRepository;
}
// call jOOQ DAO
@Transactional(readOnly = true)
public List<Sale> fetchSaleByFiscalYear(int year) {
return saleRepository.fetchByFiscalYear(year);
}
// call your DAO
public List<Sale> fetchSaleAscGtLimit(double limit) {
return saleRepository.findSaleAscGtLimit(limit);
}
}
请注意以下说明。
重要提示
在编写本文时,jOOQ DAO 在以下语句下工作:
jOOQ DAO 可以实例化任意多次,因为它们没有自己的状态。
jOOQ DAO 无法为使用 POJO 接口而不是类的 DAO 生成方法。实际上,在撰写本文时,<interfaces/> 和 <immutableInterfaces/> 功能已被提议移除。您可以在此处跟踪此问题:github.com/jOOQ/jOOQ/issues/10509。
jOOQ 无法为 DAO 生成接口。
jOOQ DAO 可以用 @Repository 注解,但它们默认不在事务上下文中运行(jOOQ 生成器不能将它们注解为 @Transactional)。您可以在此处跟踪此问题:github.com/jOOQ/jOOQ/issues/10756。
生成的 DAO 的 insert() 方法无法从数据库或 POJO 返回新生成的 ID。它只是返回 void。您可以在此处跟踪此问题:github.com/jOOQ/jOOQ/issues/2536 和 github.com/jOOQ/jOOQ/issues/3021。
您不必将这些缺点视为终点。jOOQ 团队过滤了数十个功能,以便选择最流行的、适用于大量场景并值得直接在 jOOQ 发布中实现的功能。尽管如此,任何边缘情况或特殊情况功能都可以通过自定义生成器、自定义策略或客户配置由您提供。
本节中的完整示例命名为 jOOQ DAO。
摘要
在本章中,我们介绍了从零开始开发 DAO 层或演进 jOOQ 生成的 DAO 层在 Spring Boot 和 jOOQ 应用程序中的几种方法。所展示的每个应用程序都可以作为您自己应用程序的占位符应用程序。只需选择适合您的一个,替换模式,然后开始开发。
在下一章中,我们将使用 jOOQ 表达涉及 SELECT、INSERT、UPDATE 和 DELETE 的广泛查询。
第五章:处理不同类型的 SELECT、INSERT、UPDATE、DELETE 和 MERGE
jOOQ 初学者常见的场景来自于有一个应该通过 jOOQ DSL API 表达的标准有效的 SQL。虽然 jOOQ DSL API 非常直观且易于学习,但缺乏实践仍然可能导致我们无法找到或直观地找到应该链式调用的适当 DSL 方法来表达特定的 SQL。
本章通过一系列流行的查询来解决这个问题,这给了你基于 Java 模式练习 jOOQ DSL 语法的机遇。更确切地说,我们的目标是使用 jOOQ DSL 语法表达、收集我们日常工作中使用的SELECT、INSERT、UPDATE、DELETE和MERGE语句的精心挑选列表。
这样,到本章结束时,你应该已经通过 jOOQ DSL 语法过滤了大量的 SQL 语句,并在基于 Maven 和 Gradle 的 Java 应用程序中针对 MySQL、PostgreSQL、SQL Server 和 Oracle 数据库进行了尝试。由于 jOOQ DSL 是无方言的,它擅长通过模拟有效语法来处理大量的方言特定问题,因此这也是尝试这四个流行数据库这一方面的好机会。
注意,即使你看到了一些性能提示,我们的重点并不是寻找最佳 SQL 或针对特定用例的最优 SQL。这并不是我们的目标!我们的目标是学习 jOOQ DSL 语法,达到一个能够以高效方式编写几乎所有SELECT、INSERT、UPDATE、DELETE和MERGE语句的水平。
在这种情况下,我们的议程包括以下内容:
-
表达
SELECT语句 -
表达
INSERT语句 -
表达
UPDATE语句 -
表达
DELETE语句 -
表达
MERGE语句
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter05。
表达 SELECT 语句
在本节中,我们将通过 jOOQ DSL 语法表达/编写一系列SELECT语句,包括常见的投影、流行的子查询、标量相关子查询、并集和行值表达式。我们将从常用的投影开始。
表达常用投影
通过常用投影,我们理解的是针对众所周知的虚拟表DUAL的投影。正如你可能知道的,DUAL表是 Oracle 特有的;在 MySQL 中(尽管 jOOQ 仍然为了 MySQL 5.7 的兼容性而生成它),它通常是不必要的,而在 PostgreSQL 和 SQL Server 中则不存在。
在此上下文中,即使 SQL 标准要求一个FROM子句,jOOQ 也从不要求这样的子句,并在需要/支持时渲染DUAL表。例如,选择*0*和*1*可以通过selectZero()和selectOne()方法完成(这些静态方法在org.jooq.impl.DSL中可用)。下面将举例说明后者(selectOne()),包括一些替代方案:
MySQL 8.x : select 1 as `one`
PostgreSQL (no dummy-table concept): select 1 as "one"
ctx.selectOne().fetch();
ctx.select(val(1).as("one")).fetch();
ctx.fetchValue((val(1).as("one")));
作为旁注,DSL 类还公开了三个用于表达常用0字面量(DSL.zero())、1字面量(DSL.one())和2字面量(DSL.two())的辅助器。因此,虽然selectZero()会导致一个新的 DSL 子查询,用于常量0字面量,但zero()本身代表0字面量。选择特定值可以如下进行(由于我们无法在select()中使用普通值,我们依赖于在第三章,jOOQ 核心概念中引入的val()方法来获取适当的参数):
Oracle: select 1 "A", 'John' "B", 4333 "C", 0 "D" from dual
ctx.select(val(1).as("A"), val("John").as("B"),
val(4333).as("C"), val(false).as("D")).fetch();
或者,可以通过values()表构造函数来完成,这允许我们表达内存中的临时表。在 jOOQ 中,values()表构造函数可以用来创建可以在SELECT语句的FROM子句中使用的表。注意我们如何为values()构造函数指定列别名(“派生列列表”)以及表别名("t"):
MySQL:
select `t`.`A`, ..., `t`.`D`
from (select null as `A`, ..., null as `D`
where false
union all
select * from
(values row ('A', 'John', 4333, false)) as `t`
) as `t`
ctx.select().from(values(row("A", "John", 4333, false))
.as("t", "A", "B", "C", "D")).fetch();
这里是selectOne()的另一个替代方案:
PostgreSQL (no dummy-table concept):
select "t"."one" from (values (1)) as "t" ("one")
ctx.select().from(values(row(1)).as("t", "one")).fetch();
我们还可以指定一个显式的FROM子句来指出一些特定的表。以下是一个示例:
SQL Server:
select 1 [one] from [classicmodels].[dbo].[customer],
[classicmodels].[dbo].[customerdetail]
ctx.selectOne().from(CUSTOMER, CUSTOMERDETAIL).fetch();
当然,提供selectOne()及其类似功能的目的并不是真正为了允许查询ctx.selectOne().fetch(),而是在投影无关的查询中使用,如下例所示:
ctx.deleteFrom(SALE)
.where(exists(selectOne().from(EMPLOYEE)
// .whereExists(selectOne().from(EMPLOYEE)
.where(SALE.EMPLOYEE_NUMBER.eq(EMPLOYEE.EMPLOYEE_NUMBER)
.and(EMPLOYEE.JOB_TITLE.ne("Sales Rep")))))
.execute();
在本书附带代码中,你可以找到更多未在此列出的示例。花些时间探索CommonlyUsedProjections应用程序。接下来,让我们解决SELECT子查询或子选择。
表达SELECT以获取所需的数据
从 jOOQ DSL 开始,可能的目标是简单的SELECT查询,例如SELECT all_columns FROM table或SELECT * FROM table类型。这种查询可以用 jOOQ 写成如下形式:
ctx.select().from(ORDER)
.where(ORDER.ORDER_ID.eq(10101L)).fetch();
ctx.selectFrom(ORDER)
.where(ORDER.ORDER_ID.eq(10101L)).fetch();
ctx.select(ORDER.fields()).from(ORDER)
.where(ORDER.ORDER_ID.eq(10101L)).fetch();
由于我们依赖于生成的基于 Java 的模式(如你在第二章,自定义 jOOQ 参与级别)中看到的,jOOQ 可以推断出ORDER表的字段(列),并在生成的查询中明确地渲染它们。但是,如果你需要渲染*本身而不是字段列表,则可以使用方便的asterisk()方法,如下面的查询所示:
ctx.select(asterisk()).from(ORDER)
.where(ORDER.ORDER_ID.eq(10101L))
.fetch();
如 Lukas Eder 所提到的:"也许在这里更加强调一下,星号 (*) 并不等于查询所有列的其他三种方式。星号 (*) 会从实时数据库模式中投影所有列,包括 jOOQ 不知道的列。其他三种方法会投影 jOOQ 知道的列,但这些列可能不再存在于实时数据库模式中。可能存在不匹配,这在映射到记录(例如,使用 selectFrom()* 或 into(recordtype))时尤其重要。即使如此,当使用 * 并且在 from() 中的所有表都为 jOOQ 所知时,jOOQ 将尝试展开星号以访问所有转换器和数据类型绑定,以及可嵌入的记录和其他事物*。"
此外,请注意,此类查询可能会获取比所需更多的数据,而依赖 * 而不是列的列表可能会带来性能惩罚,这在本文中有所讨论:tanelpoder.com/posts/reasons-why-select-star-is-bad-for-sql-performance/。当我提到 "可能获取比所需更多的数据" 时,我指的是仅处理获取结果集的子集的场景,而其余的则被简单地丢弃。获取数据可能是一项昂贵的操作(尤其是耗时操作),因此,只是为了丢弃而获取数据是一种资源浪费,并且可能导致长时间运行的交易,影响应用程序的可扩展性。这在基于 JPA 的应用程序中是一个常见的场景(例如,在 Spring Boot 中,spring.jpa.open-in-view=true可能会导致加载比所需更多的数据)。
在其他方面,Tanel Poder 的文章提到一个很多初学者容易忽视的问题。通过强制数据库执行 "无用的强制工作"(你一定会喜欢这篇文章:blog.jooq.org/2017/03/08/many-sql-performance-problems-stem-from-unnecessary-mandatory-work/) 通过 * 投影,它将无法应用一些查询优化,例如,连接消除,这对于复杂查询是至关重要的 (tanelpoder.com/posts/reasons-why-select-star-is-bad-for-sql-performance/#some-query-plan-optimizations-not-possible)。
获取列的子集
一般来说,获取比所需更多的数据是持久层性能惩罚的常见原因。因此,如果你只需要 ORDER 表的子集列,那么只需在 SELECT 中明确列出它们,例如 select(ORDER.ORDER_ID, ORDER.ORDER_DATE, ORDER.REQUIRED_DATE, ORDER.SHIPPED_DATE, ORDER.CUSTOMER_NUMBER)。有时,所需的子集列几乎等于(但不等于)字段/列的总数。在这种情况下,与其像之前那样列出子集列,不如通过 except() 方法指出应排除的字段/列。以下是一个从 ORDER 中获取所有字段/列,除了 ORDER.COMMENTS 和 ORDER.STATUS 的示例:
ctx.select(asterisk().except(ORDER.COMMENTS, ORDER.STATUS))
.from(ORDER)
.where(ORDER.ORDER_ID.eq(10101L)).fetch();
这里有一个例子,它将 SQL nvl() 函数应用于 OFFICE.CITY 字段。每当 OFFICE.CITY 为 null 时,我们获取 N/A 字符串:
ctx.select(nvl(OFFICE.CITY, "N/A"),
OFFICE.asterisk().except(OFFICE.CITY))
.from(OFFICE).fetch();
如果你需要给一个条件附加别名,那么我们首先需要通过 field() 方法将这个条件包装在一个字段中。以下是一个示例:
ctx.select(field(SALE.SALE_.gt(5000.0)).as("saleGt5000"),
SALE.asterisk().except(SALE.SALE_))
.from(SALE).fetch();
此外,这个查询的结果集表头看起来如下:

注意,* EXCEPT (...) 语法灵感来源于 BigQuery,它也计划实现一个 * REPLACE (...) 语法。你可以在这里跟踪其进度:github.com/jOOQ/jOOQ/issues/11198。
在本书附带代码(SelectOnlyNeededData)中,你可以看到更多使用 asterisk() 和 except() 方法进行操作以实现涉及一个或多个表的场景的示例。
获取行子集
除了使用谓词外,通常通过 LIMIT ... OFFSET 子句来获取行子集。不幸的是,这个子句不是 SQL 标准的一部分,并且只有有限数量的数据库供应商(如 MySQL 和 PostgreSQL)理解它。尽管如此,jOOQ 允许我们通过 limit() 和 offset() 方法使用 LIMIT ... OFFSET,并将处理所有用于使用方言的兼容语法方面。以下是一个渲染 LIMIT 10 OFFSET 5 的示例:
ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, EMPLOYEE.SALARY)
.from(EMPLOYEE)
.orderBy(EMPLOYEE.SALARY)
.limit(10)
.offset(5)
.fetch();
这里是相同的事情(相同的查询和结果),但通过 limit(Number offset, Number numberOfRows) 风味表达(请注意,偏移量是第一个参数——从 MySQL 继承的参数顺序):
ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME,EMPLOYEE.SALARY)
.from(EMPLOYEE)
.orderBy(EMPLOYEE.SALARY)
.limit(5, 10)
.fetch();
此外,jOOQ 将根据数据库供应商渲染以下 SQL:
MySQL and PostgreSQL (jOOQ 3.14):
select ... from ...limit 10 offset 5
PostgreSQL (jOOQ 3.15+):
select ... from ...offset 5 rows fetch first 10 rows only
SQL Server:
select ... from ...offset 5 rows fetch next 10 rows only
Oracle:
select ... from ...offset 5 rows fetch next 10 rows only
通常,LIMIT 和 OFFSET 的参数是一些硬编码的整数。但是,jOOQ 允许我们使用 Field。例如,在这里我们使用一个标量子查询在 LIMIT 中(同样可以在 OFFSET 中做同样的事情):
ctx.select(ORDERDETAIL.ORDER_ID, ORDERDETAIL.PRODUCT_ID, ORDERDETAIL.QUANTITY_ORDERED)
.from(ORDERDETAIL)
.orderBy(ORDERDETAIL.QUANTITY_ORDERED)
.limit(field(select(min(ORDERDETAIL.QUANTITY_ORDERED)). from(ORDERDETAIL)))
.fetch();
作为 Lukas Eder 的笔记,“从版本 3.15 开始,jOOQ 为 PostgreSQL 中的OFFSET … FETCH生成标准 SQL,而不是供应商特定的LIMIT … OFFSET。这是为了提供对FETCH NEXT … ROWS WITH TIES的原生支持。也许,未来的 jOOQ 也会提供 SQL 标准 2011 中 Oracle 的/SQL Server 的语法:OFFSET n ROW[S] FETCH { FIRST | NEXT } m [ PERCENT ] ROW[S] { ONLY | WITH TIES }。”跟踪这里:github.com/jOOQ/jOOQ/issues/2803。
注意,之前的查询使用了显式的ORDER BY来避免不可预测的结果。如果我们省略ORDER BY,那么 jOOQ 将在需要时代表我们模拟它。例如,OFFSET(与TOP不同)在 SQL Server 中需要ORDER BY,如果我们省略ORDER BY,那么 jOOQ 将代表我们生成以下 SQL:
SQL Server:
select ... from ...
order by (select 0)
offset n rows fetch next m rows only
由于我们提到了模拟这个话题,让我们提醒一下你应该注意的事项。
重要提示
jOOQ 最酷的特性之一是能够在用户数据库缺少特定功能时模拟有效的和最优的 SQL 语法(建议阅读这篇文章:blog.jooq.org/2018/03/13/top-10-sql-dialect-emulations-implemented-in-jooq/)。jOOQ 文档提到,“未使用org.jooq.Support注解(@Support)的 jOOQ API 方法,或者使用@Support注解但没有指定任何 SQL 方言的方法,可以在所有 SQL 方言中安全使用。上述@Support注解不仅指定了哪些数据库原生支持某个功能。它还表明,对于缺少此功能的某些数据库,jOOQ 会模拟该功能。”此外,每当 jOOQ 不支持某些供应商特定的功能/语法时,解决方案是使用纯 SQL 模板。这一点将在本书的后续章节中详细说明。
更多示例,请考虑本书附带代码。你将找到 15+ 个示例,包括几个边缘情况。例如,在 EXAMPLE 10.1 和 10.2 中,你可以看到通过 jOOQ 的 sortAsc() 方法按特定顺序检索行的示例(如果你处于这种位置,我建议你也阅读这篇文章:blog.jooq.org/2014/05/07/how-to-implement-sort-indirection-in-sql/)。或者,在 EXAMPLE 11 中,你可以看到如何通过 jOOQ 的 org.jooq.Comparator API 和一个布尔变量在运行时选择 WHERE ... IN 和 WHERE ... NOT IN 语句。此外,在 EXAMPLE 15 和 16 中,你可以看到使用 SelectQuery API 从不同表中检索列的用法。花时间练习这些示例。我非常确信,你会从中学到很多技巧。应用程序名为 SelectOnlyNeededData。现在,让我们来谈谈如何在 jOOQ 中表达子查询。
表达 SELECT 子查询(子选择)
大概来说,一个 SELECT 子查询(或子选择)是由嵌套在另一个 SELECT 语句中的 SELECT 语句表示的。通常,它们出现在 WHERE 或 FROM 子句中,但它们出现在 HAVING 子句或与数据库视图结合使用也不足为奇。
例如,让我们以以下包含子查询的 WHERE 子句的普通 SQL 语句为例,作为谓语的一部分:
SELECT first_name, last_name FROM employee
WHERE office_code IN
(SELECT office_code FROM office WHERE city LIKE "S%")
在 jOOQ 中,我们可以直接表达这个查询:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.OFFICE_CODE.in(
select(OFFICE.OFFICE_CODE).from(OFFICE)
.where(OFFICE.CITY.like("S%")))).fetch()
注意我们是如何通过 jOOQ 的 in() 方法使用 IN 语句的。同样地,你可以使用 jOOQ 支持的其他语句,例如 NOT IN (notIn()), BETWEEN (between()), LIKE (like()),以及许多其他语句。始终注意使用 NOT IN,并且要注意它来自子查询的关于 NULL 的特殊行为(www.jooq.org/doc/latest/manual/sql-building/conditional-expressions/in-predicate/)。
几乎任何 SQL 语句都有一个 jOOQ 等价实现,因此,花时间扫描 jOOQ API 尽可能多地覆盖它。这是成为 jOOQ 高级用户的正确方向。
重要提示
在类型为 SELECT foo... 的子查询中,(SELECT buzz) 是一个常见的案例,可以使用 DSLContext.select() 和 DSL.select() 作为 ctx.select(foo) ... (select(buzz))。DSLContext.select() 方法用于外部的 SELECT 以获取数据库配置连接的引用,而对于内部或嵌套的 SELECT,我们可以使用 DSL.select() 或 DSLContext.select()。然而,对于内部 SELECT 使用 DSL.select() 更方便,因为它可以静态导入并简单地作为 select() 引用。请注意,使用 DSL.select() 对两种类型的 SELECT 或仅对内部 SELECT 使用会导致类型为 Cannot execute query. No Connection configured 的异常。但当然,您仍然可以通过 DSLContext.fetch(ResultQuery) 等方式执行 DSL.select()。
接下来,让我们看看另一个简单的 SQL,它在 FROM 子句中有一个子查询,如下所示:
SELECT sale_id, sale
FROM sale,
(SELECT avg(sale) AS avgs,employee_number AS sen
FROM sale
GROUP BY employee_number) AS saleTable
WHERE (employee_number = saleTable.sen
AND sale < saleTable.avgs);
这次,让我们通过派生表以下列方式表达子查询:
// Table<Record2<BigDecimal, Long>>
var saleTable = select(avg(SALE.SALE_).as("avgs"),
SALE.EMPLOYEE_NUMBER.as("sen"))
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER)
.asTable("saleTable"); // derived table
另一种方法依赖于 table(select(…))。实际上,table(Select<R>) 是 asTable() 的同义词。选择您认为更流畅的一个:
var saleTable = table(select(...)).as("saleTable");
接下来,我们可以使用这个派生表来表示外部的 SELECT:
ctx.select(SALE.SALE_ID, SALE.SALE_)
.from(SALE, saleTable)
.where(SALE.EMPLOYEE_NUMBER
.eq(saleTable.field("sen", Long.class))
.and(SALE.SALE_
.lt(saleTable.field("avgs", Double.class))))
.fetch();
由于 jOOQ 无法推断用户定义的派生表的字段类型,我们可以依靠强制转换来填充 sen 和 avgs 字段的预期类型(为了快速回顾 强制转换 的目标,请重新阅读 第三章,jOOQ 核心概念),或 saleTable.field(name, type),就像这里一样。
jOOQ API 非常灵活和丰富,它允许我们以多种方式表达相同的 SQL。这取决于我们在特定场景中选择最方便的方法。例如,如果我们认为 SQL 部分 WHERE (employee_number = saleTable.sen AND sale < saleTable.avgs) 可以写成 WHERE (employee_number = sen AND sale < avgs),那么我们可以提取以下字段作为局部变量:
Field<Double> avgs = avg(SALE.SALE_)
.coerce(Double.class).as("avgs");
Field<Long> sen = SALE.EMPLOYEE_NUMBER.as("sen");
然后,我们可以在派生表和外部的 SELECT 中使用它们:
var saleTable = select(avgs, sen)
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER)
.asTable("saleTable"); // derived table
ctx.select(SALE.SALE_ID, SALE.SALE_)
.from(SALE, saleTable)
.where(SALE.EMPLOYEE_NUMBER.eq(sen)
.and(SALE.SALE_.lt(avgs)))
.fetch();
或者,我们可以消除显式导出的表,并以流畅的方式嵌入子查询:
ctx.select(SALE.SALE_ID, SALE.SALE_)
.from(SALE, select(avgs, sen)
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER))
.where(SALE.EMPLOYEE_NUMBER.eq(sen)
.and(SALE.SALE_.lt(avgs)))
.fetch();
注意,我们还移除了显式的 saleTable 别名。因为 MySQL 抱怨(并且不仅仅是),每个派生表都需要一个别名,但我们不必担心。jOOQ 会代表我们生成别名(类似于 alias_25088691)。但是,如果您的基准测试表明别名生成不可忽略,那么提供显式别名会更好。正如 Lukas Eder 所说,“*然而,生成的别名是基于 SQL 字符串的确定性,为了稳定,这对于执行计划缓存(例如,Oracle、SQL Server,在 MySQL 和 PostgreSQL 中无关紧要)很重要。””
在捆绑的代码中考虑更多示例。例如,如果您对在 INSERT、UPDATE 和 DELETE 中嵌套 SELECT 感兴趣,那么您可以在捆绑的代码中找到示例。应用程序名为 SampleSubqueries。接下来,让我们谈谈标量子查询。
表达标量子查询
标量子查询只选择一个列/表达式,并返回一行。它可以在 SQL 查询的任何位置使用,只要可以使用列/表达式的地方。例如,让我们假设一个简单的 SQL 查询,该查询选择薪水大于或等于平均薪水加 25,000 的员工:
SELECT first_name, last_name FROM employee
WHERE salary >= (SELECT (AVG(salary) + 25000) FROM employee);
在 jOOQ 中,这个查询是通过以下代码生成的:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.coerce(BigDecimal.class)
.ge(select(avg(EMPLOYEE.SALARY).plus(25000))
.from(EMPLOYEE))).fetch();
你注意到 ...ge(select(...)) 结构了吗?在 ge() 的左边我们有一个 Field,但在右边我们有一个 Select。这是由于 ge(Select<? extends Record1<T>> select) 的存在,这是一个非常方便的快捷方式,可以节省我们显式使用 field() 作为 ge(field(select(...))),或者使用 asField() 作为 ge(select(...).asField()) 的麻烦。我们也可以写出像 select(...).ge(select(...)) 这样的条件。但是,field() 和 asField() 之间有什么区别?
让我们再举一个例子,这个例子中插入了一个新的 PRODUCT(我只列出了相关部分):
ctx.insertInto(PRODUCT,
PRODUCT.PRODUCT_ID, ..., PRODUCT.MSRP)
.values(...,
field(select(avg(PRODUCT.MSRP)).from(PRODUCT)))
.execute();
将 PRODUCT.MSRP 的值作为平均 Field 插入。这可以通过我们之前通过 field(Select<? extends Record1<T>> s) 或通过 asField() 做到。如果我们选择 asField(),那么我们会写出类似以下的内容:
...select(avg(PRODUCT.MSRP)).from(PRODUCT).asField())
但是,asField() 方法返回的结果提供者是一个 Field<?/Object> 对象。换句话说,asField() 丢失了类型信息,因此不是类型安全的。通过丢失类型信息,asField() 允许我们意外地引入类型安全相关的错误,这些错误直到运行时才能检测到,甚至更糟糕的是,会产生意外的结果。在这里,我们使用了 array(PRODUCT.MSRP) 而不是 avg(PRODUCT.MSRP),但我们直到运行时都没有任何抱怨:
...select(array(PRODUCT.MSRP)).from(PRODUCT).asField())
当然,你不会写出这样的废话,但想法是,在这样的上下文中使用 asField() 容易出现其他数据类型不兼容的问题,这些问题可能很难发现,并可能产生意外的结果。所以,让我们保留 asField() 用于查询,如 SELECT b.*, (SELECT foo FROM a) FROM b,并专注于 field():
... field(select(array(PRODUCT.MSRP)).from(PRODUCT)))
你认为这段代码会编译吗?正确的答案是不会!你的 IDE 会立即发出数据类型不兼容的信号。虽然 PRODUCT.MSRP 是 BigDecimal 类型,但 (array(PRODUCT.MSRP)) 是 Field<BigDecimal[]> 类型,所以 INSERT 是错误的。将 array() 替换为 avg()。问题解决!
在捆绑的代码(ScalarSubqueries)中,你会有更多示例,包括在 INSERT、UPDATE 和 DELETE 中使用嵌套的标量查询。接下来,让我们谈谈相关子查询。
表达相关子查询
相关子查询(或重复子查询)使用外部查询的值来计算其值。由于它依赖于外部查询,相关子查询不能作为一个独立的子查询独立执行。正如 Lukas Eder 提到的,“*在任何情况下,没有 RDBMS 被迫对每个由外部查询评估的行天真地执行一次相关子查询(显然,如果这种情况发生,那么这可能会带来性能开销,如果相关子查询必须执行多次)。许多 RDBMS 会通过将转换应用于连接或半连接来优化相关子查询。其他,如 Oracle 11g 及以后版本,通过标量子查询缓存来优化相关子查询。” (blogs.oracle.com/oraclemagazine/on-caching-and-evangelizing-sql)
让我们考虑以下表示相关标量子查询的简单 SQL:
SELECT s1.sale, s1.fiscal_year, s1.employee_number
FROM sale AS s1
WHERE s1.sale =
(SELECT max(s2.sale)
FROM sale AS s2
WHERE (s2.employee_number = s1.employee_number
AND s2.fiscal_year = s1.fiscal_year))
ORDER BY s1.fiscal_year
在 jOOQ 中表达这个查询可以这样进行(注意我们希望在渲染的 SQL 中保留s1和s2别名):
Sale s1 = SALE.as("s1");
Sale s2 = SALE.as("s2");
ctx.select(s1.SALE_, s1.FISCAL_YEAR, s1.EMPLOYEE_NUMBER)
.from(s1)
.where(s1.SALE_.eq(select(max(s2.SALE_))
.from(s2)
.where(s2.EMPLOYEE_NUMBER.eq(s1.EMPLOYEE_NUMBER)
.and(s2.FISCAL_YEAR.eq(s1.FISCAL_YEAR)))))
.orderBy(s1.FISCAL_YEAR)
.fetch();
你是否注意到了这个:...where(s1.SALE_.eq(select(max(s2.SALE_))?不需要asField() /field()!查询不需要调用asField()/field(),因为 jOOQ 提供了一个便利的重载,即Field<T>.eq(Select<? extends Record1<T>>)。是的,我知道我之前已经告诉过你,但我只是想再次强调。
然而,正如你可能直觉到的,这个相关子查询,它依赖于重复自连接的表,可以通过以下GROUP BY更有效地表达(这次,我们不保留别名):
ctx.select(SALE.FISCAL_YEAR,
SALE.EMPLOYEE_NUMBER, max(SALE.SALE_))
.from(SALE)
.groupBy(SALE.FISCAL_YEAR, SALE.EMPLOYEE_NUMBER)
.orderBy(SALE.FISCAL_YEAR)
.fetch();
在这种情况下,GROUP BY要好得多,因为它消除了自连接,将O(n2)转换为O(n)。正如 Lukas Eder 分享的,“随着现代 SQL 的发展,自连接几乎不再真正需要。初学者可能会认为这些自连接是可行的,但它们可能会带来很大的损害,twitter.com/MarkusWinand/status/1118147557828583424,最坏情况下为 O(n2)*。”所以,在跳入编写这样的相关子查询之前,尝试评估一些替代方案并比较执行计划。
让我们看看以下简单 SQL 的另一个例子:
SELECT product_id, product_name, buy_price
FROM product
WHERE
(SELECT avg(buy_price)
FROM product) > ANY
(SELECT price_each
FROM orderdetail
WHERE product.product_id = orderdetail.product_id);
因此,这个查询比较了所有产品的平均购买价格(或标价)与每个产品的所有销售价格。如果这个平均值大于某个产品的任何销售价格,那么该产品将被检索到结果集中。在 jOOQ 中,可以这样表达:
ctx.select(PRODUCT.PRODUCT_ID,
PRODUCT.PRODUCT_NAME, PRODUCT.BUY_PRICE)
.from(PRODUCT)
.where(select(avg(PRODUCT.BUY_PRICE))
.from(PRODUCT).gt(any(
select(ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(PRODUCT.PRODUCT_ID
.eq(ORDERDETAIL.PRODUCT_ID)))))
.fetch();
在捆绑的代码中,你有一个包含 WHERE (NOT) EXISTS,ALL 和 ANY 等示例的完整列表。正如 Lukas Eder 所说,“值得指出的是,ALL 和 ANY 的名字是 '量词',比较称为量词比较谓词。这些比与 MIN() 或 MAX() 比较或使用 ORDER BY .. LIMIT 1 更优雅,尤其是在使用行值表达式时。” 此外,你还可以查看使用嵌套在 INSERT,UPDATE 和 DELETE 中的相关子查询的示例。该应用程序名为 CorrelatedSubqueries。接下来,让我们来谈谈在 jOOQ 中编写行表达式。
表达行表达式
行值表达式对于编写优雅的多行谓词非常有用。jOOQ 通过 org.jooq.Row 接口表示行值表达式。其用法简单,如下面的普通 SQL 所示:
SELECT customer_number, address_line_first,
address_line_second, city, state, postal_code, country
FROM customerdetail
WHERE (city, country) IN(SELECT city, country FROM office)
在 jOOQ 中,这可以通过 row() 表达,如下所示:
ctx.selectFrom(CUSTOMERDETAIL)
.where(row(CUSTOMERDETAIL.CITY, CUSTOMERDETAIL.COUNTRY)
.in(select(OFFICE.CITY, OFFICE.COUNTRY).from(OFFICE)))
.fetch();
在捆绑的代码(RawValueExpression)中,你可以练习使用行值表达式与比较谓词、BETWEEN 和 OVERLAPS 谓词(jOOQ 支持重叠日期和任意行值表达式的 2 度 – 这有多酷?!)以及 NULL 的示例。接下来,让我们来处理 UNION 和 UNION ALL 操作符。
表达 UNION 和 UNION ALL 操作符
UNION 和 UNION ALL 操作符用于将来自不同 SELECT 语句或 SELECT 的两个或多个结果集合并为一个结果集。UNION 从 SELECT 语句的结果中消除重复行,而 UNION ALL 不做此操作。要正常工作,两个查询中的列数和顺序必须对应,并且数据类型必须相同或至少兼容。让我们考虑以下 SQL:
SELECT concat(first_name, ' ', last_name) AS full_name,
'Employee' AS contactType
FROM employee
UNION
SELECT concat(contact_first_name, ' ', contact_last_name),
'Customer' AS contactType
FROM customer;
jOOQ 通过 union() 方法渲染 UNION,通过 unionAll() 方法渲染 UNION ALL。前面的 SQL 通过 union() 渲染如下:
ctx.select(
concat(EMPLOYEE.FIRST_NAME, inline(" "),
EMPLOYEE.LAST_NAME).as("full_name"),
inline("Employee").as("contactType"))
.from(EMPLOYEE)
.union(select(
concat(CUSTOMER.CONTACT_FIRST_NAME, inline(" "),
CUSTOMER.CONTACT_LAST_NAME),
inline("Customer").as("contactType"))
.from(CUSTOMER))
.fetch();
Lukas Eder 指出,“UNION (ALL) 在处理 NULL 方面与其他操作符不同,这意味着两个 NULL 值是 '不区分的'。因此,SELECT NULL UNION SELECT NULL 只产生一行,就像 SELECT NULL INTERSECT SELECT NULL。”
在捆绑的代码中,你可以练习更多示例,包括 UNION 和 ORDER BY,UNION 和 LIMIT,UNION 和 HAVING,UNION 和 SELECT INTO(MySQL 和 PostgreSQL),UNION ALL 等。不幸的是,这里没有足够的空间来列出并分析这些示例,因此,请考虑名为 SelectUnions 的应用程序。接下来,让我们来探讨 INTERSECT 和 EXCEPT 操作符。
表达 INTERSECT (ALL) 和 EXCEPT (ALL) 操作符
INTERSECT运算符仅产生由(或共同于)两个子查询返回的值(行)。EXCEPT运算符(或 Oracle 中的MINUS)仅产生出现在第一个(或左侧)子查询中且不出现在第二个(或右侧)子查询中的值。虽然INTERSECT和EXCEPT会从其结果中删除重复项,但INTERSECT ALL和EXCEPT ALL不会这样做。与UNION的情况一样,要正常工作,两个查询中的列数和顺序必须对应,并且数据类型必须相同或至少兼容。
让我们考虑以下纯 SQL:
SELECT buy_price FROM product
INTERSECT
SELECT price_each FROM orderdetail
在 jOOQ 中,这可以通过以下intersect()方法表达(对于渲染INTERSECT ALL,使用intersectAll()方法):
ctx.select(PRODUCT.BUY_PRICE)
.from(PRODUCT)
.intersect(select(ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL))
.fetch();
通过将 SQL 中的INTERSECT替换为EXCEPT,并在 jOOQ 中将intersect()方法替换为except(),我们可以获得一个EXCEPT用例(对于EXCEPT ALL,使用exceptAll()方法)。以下是纯 SQL(这次,让我们添加一个ORDER BY子句):
SELECT buy_price FROM product
EXCEPT
SELECT price_each FROM orderdetail
ORDER BY buy_price
jOOQ 的代码如下:
ctx.select(PRODUCT.BUY_PRICE)
.from(PRODUCT)
.except(select(ORDERDETAIL.PRICE_EACH).from(ORDERDETAIL))
.orderBy(PRODUCT.BUY_PRICE)
.fetch();
然而,如果你的数据库不支持这些运算符(例如,MySQL),那么你必须模拟它们。有几种方法可以实现这一点,在名为IntersectAndExcept的应用程序中(用于 MySQL),你可以看到基于IN(当没有重复项或NULL时很有用)和WHERE EXISTS模拟INTERSECT(ALL)的非详尽解决方案列表,并基于LEFT OUTER JOIN和WHERE NOT EXISTS模拟EXCEPT(ALL)。当然,你也可以查看IntersectAndExcept的 MySQL、SQL Server 和 Oracle 的示例。注意,Oracle 18c(用于我们的应用程序)仅支持INTERSECT和EXCEPT,而 Oracle20c 支持所有这四个运算符。接下来,让我们解决众所周知的SELECT DISTINCT和更多内容。
表达独特性
jOOQ 提供了一套方法来表达查询中的独特性。我们有以下方法:
-
SELECT DISTINCT通过selectDistinct() -
IS(NOT)DISTINCT FROM通过isDistinctFrom()和isNotDistinctFrom() -
COUNT (DISTINCT...)通过countDistinct() -
AVG/SUM/MIN/MAX (DISTINCT ...)通过avg/sum/min/maxDistinct() -
PostgreSQL
DISTINCT ON通过selectDistinct().on()或distinctOn()
让我们看看以下IS DISTINCT FROM的示例(在 MySQL 中,IS DISTINCT FROM由<=>运算符表示):
SELECT office.office_code, ...
FROM office
JOIN customerdetail
ON office.postal_code = customerdetail.postal_code
WHERE (not((office.city, office.country) <=>
(customerdetail.city, customerdetail.country)))
jOOQ 通过以下代码片段呈现此查询:
ctx.select()
.from(OFFICE)
.innerJoin(CUSTOMERDETAIL)
.on(OFFICE.POSTAL_CODE.eq(CUSTOMERDETAIL.POSTAL_CODE))
.where(row(OFFICE.CITY, OFFICE.COUNTRY).isDistinctFrom(
row(CUSTOMERDETAIL.CITY, CUSTOMERDETAIL.COUNTRY)))
.fetch();
根据使用的方言,jOOQ 模拟正确的语法。对于 MySQL,jOOQ 呈现<=>运算符;对于 Oracle,它依赖于DECODE和INTERSECT;对于 SQL Server,它依赖于INTERSECT。PostgreSQL 支持IS DISTINCT FROM。
接下来,让我们看看 PostgreSQL 的DISTINCT ON的一个示例:
SELECT DISTINCT ON (product_vendor, product_scale)
product_id, product_name, ...
FROM product
ORDER BY product_vendor, product_scale
jOOQ 的代码如下:
ctx.selectDistinct()
.on(PRODUCT.PRODUCT_VENDOR, PRODUCT.PRODUCT_SCALE)
.from(PRODUCT)
.orderBy(PRODUCT.PRODUCT_VENDOR, PRODUCT.PRODUCT_SCALE)
.fetch();
肯定值得在这里提到的是,jOOQ 通过row_number()窗口函数模拟了针对 MySQL、SQL Server、Oracle 等特定于 PostgreSQL 的DISTINCT ON。
当然,我们也可以编写模拟 DISTINCT ON 的 jOOQ 查询。例如,以下示例通过 jOOQ 的 rowNumber() 和 qualify() 获取每个财政年度最大销售额的员工编号:
ctx.selectDistinct(SALE.EMPLOYEE_NUMBER,
SALE.FISCAL_YEAR, SALE.SALE_)
.from(SALE)
.qualify(rowNumber().over(
partitionBy(SALE.FISCAL_YEAR)
.orderBy(SALE.FISCAL_YEAR, SALE.SALE_.desc())).eq(1))
.orderBy(SALE.FISCAL_YEAR)
.fetch();
通过 DISTINCT ON 解决的经典场景依赖于选择一些不同的列(s)同时按其他列排序。例如,以下查询依赖于 PostgreSQL 的 DISTINCT ON 来获取按最小销售额排序的不同员工编号:
ctx.select(field(name("t", "employee_number")))
.from(select(SALE.EMPLOYEE_NUMBER, SALE.SALE_)
.distinctOn(SALE.EMPLOYEE_NUMBER)
.from(SALE)
.orderBy(SALE.EMPLOYEE_NUMBER, SALE.SALE_).asTable("t"))
.orderBy(field(name("t", "sale")))
.fetch();
当然,也可以在不使用 DISTINCT ON 的情况下进行模拟。这里是一个替代方案:
ctx.select(field(name("t", "employee_number")))
.from(select(SALE.EMPLOYEE_NUMBER,
min(SALE.SALE_).as("sale"))
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER).asTable("t"))
.orderBy(field(name("t", "sale")))
.fetch();
在捆绑的代码(SelectDistinctOn)中,你可以找到涵盖之前列表中所有条目的示例。花些时间练习它们,熟悉 jOOQ 语法。此外,不要止步于这些示例;请尽可能多地用 SELECT 进行实验。
关于 SELECT 的内容就到这里。接下来,让我们开始讨论插入操作。
表达 INSERT 语句
在本节中,我们将通过 jOOQ DSL 语法表达不同类型的插入,包括 INSERT ... VALUES、INSERT ... SET、INSERT ... RETURNING 和 INSERT ...DEFAULT VALUES。让我们从众所周知的 INSERT ... VALUES 插入开始,这是大多数数据库供应商支持的。
表达 INSERT ... VALUES
jOOQ 通过 insertInto() 和 values() 方法支持 INSERT ... VALUES。可选地,我们可以使用 columns() 方法来区分我们插入的表名和插入的字段/列列表。为了触发实际的 INSERT 语句,我们必须显式调用 execute();请注意这个方面,因为 jOOQ 新手往往会在插入/更新/删除表达式的末尾忘记这个调用。此方法返回受此 INSERT 语句影响的行数作为整数值(0),这意味着没有发生任何操作。
例如,以下 jOOQ 类型安全的表达式将生成一个可以成功执行至少 MySQL、PostgreSQL、SQL Server 和 Oracle 的 INSERT 语句(ORDER 表的主键 ORDER.ORDER_ID 是自动生成的,因此可以省略):
ctx.insertInto(ORDER,
ORDER.COMMENTS, ORDER.ORDER_DATE, ORDER.REQUIRED_DATE,
ORDER.SHIPPED_DATE, ORDER.STATUS, ORDER.CUSTOMER_NUMBER,
ORDER.AMOUNT)
.values("New order inserted...", LocalDate.of(2003, 2, 12),
LocalDate.of(2003, 3, 1), LocalDate.of(2003, 2, 27),
"Shipped", 363L, BigDecimal.valueOf(314.44))
.execute();
或者,使用 columns(),可以这样表达:
ctx.insertInto(ORDER)
.columns(ORDER.COMMENTS, ORDER.ORDER_DATE,
ORDER.REQUIRED_DATE, ORDER.SHIPPED_DATE,
ORDER.STATUS, ORDER.CUSTOMER_NUMBER, ORDER.AMOUNT)
.values("New order inserted...", LocalDate.of(2003, 2, 12),
LocalDate.of(2003, 3, 1), LocalDate.of(2003, 2, 27),
"Shipped", 363L, BigDecimal.valueOf(314.44))
.execute();
如果省略了整个列/字段列表(例如,出于简洁性的原因),那么 jOOQ 表达式是非类型安全的,你必须为表中的每个字段/列显式指定一个值,包括代表自动生成主键的字段/列以及具有默认值的字段/列,否则你会得到一个异常,因为“值的数量必须与字段的数量相匹配”。此外,你必须注意值的顺序;只有当你遵循为我们在其中插入的表生成的 Record 类构造函数中定义的参数顺序时,jOOQ 才会将值与字段匹配(例如,在这个例子中,从 OrderRecord 构造函数的参数顺序)。正如卢卡斯·埃德补充说,“Record 构造函数参数的顺序也像其他所有东西一样,是从 DDL 中声明的列的顺序中派生出来的,这始终是真理的来源。”在这种情况下,为自动生成的主键(或其他字段)指定显式虚拟值可以依赖于几乎普遍(和标准 SQL)的方式,SQL DEFAULT,或者在 jOOQ 中使用 DSL.default_()/DSL.defaultValue()(尝试使用 NULL 代替 SQL DEFAULT 会导致特定实现的行为):
ctx.insertInto(ORDER)
.values(default_(), // Oracle, MySQL, PostgreSQL
LocalDate.of(2003, 2, 12), LocalDate.of(2003, 3, 1),
LocalDate.of(2003, 2, 27), "Shipped",
"New order inserted ...", 363L, 314.44)
.execute();
在 PostgreSQL 中,我们还可以使用 ORDER_SEQ.nextval() 调用;ORDER_SEQ 是与 ORDER 表关联的显式序列:
ctx.insertInto(ORDER)
.values(ORDER_SEQ.nextval(),
LocalDate.of(2003, 2, 12), LocalDate.of(2003, 3, 1),
LocalDate.of(2003, 2, 27), "Shipped",
"New order inserted ...", 363L, 314.44)
.execute();
一般而言,我们可以使用与表关联的显式或自动分配的序列(如果主键是 (BIG)SERIAL 类型),并调用 nextval() 方法。jOOQ 还定义了一个 currval() 方法,表示序列的当前值。
SQL Server 非常具有挑战性,因为它在将 IDENTITY_INSERT 设置为 OFF 时无法为标识列插入显式值 (github.com/jOOQ/jOOQ/issues/1818)。在 jOOQ 提供出优雅的解决方案之前,你可以依赖三个查询的批次:一个查询将 IDENTITY_INSERT 设置为 ON,一个查询是 INSERT,最后一个查询将 IDENTITY_INSERT 设置为 OFF。但是,即便如此,这仅适用于指定显式有效的主键。不允许使用 SQL DEFAULT 或 NULL 值,或任何其他虚拟值作为显式标识值。SQL Server 将简单地尝试使用虚拟值作为主键,并最终引发错误。正如卢卡斯·埃德所说,“在其他一些 RDBMS 中,如果你尝试插入一个显式值,即使自动生成的值是 GENERATED ALWAYS AS IDENTITY(与 GENERATED BY DEFAULT AS IDENTITY 相反),你仍然会得到一个异常。”
无论主键是否为自动生成类型,如果你显式(手动)指定它为有效值(不是虚拟值也不是重复键),那么在这四个数据库中(当然,在 SQL Server 中,在 IDENTITY_INSERT 设置为 ON 的上下文中)INSERT 操作都会成功。
重要提示
省略列列表对解释DEFAULT和标识符/序列很有趣,但真的不建议在INSERT中省略列列表。所以,你最好努力使用列列表。
对于插入多行,你可以简单地为每行添加一个values()调用,以流畅的方式或使用循环遍历行列表(通常是记录列表)并重复使用相同的values()调用,但使用不同的值。最后,别忘了调用execute()。这种方法(以及更多)在捆绑代码中可用。但是,从 jOOQ 3.15.0 版本开始,这可以通过valuesOfRecords()或valuesOfRows()来完成。例如,考虑一个记录列表:
List<SaleRecord> listOfRecord = List.of( ... );
我们可以通过valuesOfRecords()方法将此列表插入数据库,如下所示:
ctx.insertInto(SALE, SALE.fields())
.valuesOfRecords(listOfRecord)
.execute();
这里有一个包含行列表的示例:
var listOfRows
= List.of(row(2003, 3443.22, 1370L,
SaleRate.SILVER, SaleVat.MAX, 3, 14.55),
row(...), ...);
这次,我们可以使用valuesOfRows():
ctx.insertInto(SALE,
SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.RATE, SALE.VAT,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.valuesOfRows(listOfRows)
.execute();
当你需要将数据(例如,POJOs)收集到RowN的列表或数组中时,你可以使用内置的toRowList()或toRowArray()收集器。你可以在捆绑的代码中找到示例。
在其他想法中,插入Record可以通过几种方式完成;为了快速回顾 jOOQ 记录,请重新阅读第三章,jOOQ 核心概念。现在,让我们插入以下与SALE表对应的SaleRecord:
SaleRecord sr = new SaleRecord();
sr.setFiscalYear(2003); // or, sr.set(SALE.FISCAL_YEAR, 2003);
sr.setSale(3443.22); // or, sr.set(SALE.SALE_, 3443.22);
sr.setEmployeeNumber(1370L);
sr.setFiscalMonth(3);
sr.setRevenueGrowth(14.55);
要插入sr,我们执行以下操作:
ctx.insertInto(SALE)
.values(sr.getSaleId(), sr.getFiscalYear(), sr.getSale(),
sr.getEmployeeNumber(), default_(), SaleRate.SILVER,
SaleVat.MAX, sr.getFiscalMonth(), sr.getFiscalYear(),
default_())
.execute()
或者,我们可以这样做:
ctx.insertInto(SALE)
.values(sr.valuesRow().fields())
.execute();
或者,我们甚至可以这样做:
ctx.executeInsert(sr);
或者,我们可以通过attach()方法将记录附加到当前配置:
sr.attach(ctx.configuration());
sr.insert();
尝试插入一个 POJO 需要我们首先将其包装在相应的Record中。这可以通过newRecord()方法完成,该方法可以从你的 POJO 或 jOOQ 生成的 POJO 中加载 jOOQ 生成的记录。以下是对 jOOQ 生成的Sale POJO 的示例:
// jOOQ Sale POJO
Sale sale = new Sale(null, 2005, 343.22, 1504L,
null, SaleRate.SILVER, SaleVat.MAX, 4, 15.55, null);
ctx.newRecord(SALE, sale).insert();
另一种方法依赖于方便的Record.from(POJO)方法,如下所示(基本上,这次你使用SaleRecord的显式实例):
SaleRecord sr = new SaleRecord();
sr.from(sale); // sale is the previous POJO instance
ctx.executeInsert(sr);
Record.from()有几种风味,允许我们从数组或甚至值Map中填充Record。
当你需要重置Record主键(或其他字段)时,可以像以下场景中那样调用reset()方法,这会重置手动分配的主键,并允许数据库代为生成一个:
Sale sale = new Sale(1L, 2005, 343.22, 1504L, null,
SaleRate.SILVER, SaleVat.MAX, 6, 23.99, null);
SaleRecord sr = new SaleRecord();
sr.from(sale);
// reset the current ID and allow DB to generate one
sr.reset(SALE.SALE_ID);
ctx.executeInsert(sr);
然而,reset()方法会重置已更改标志(跟踪记录更改)和值(在这种情况下,主键)。如果你想只重置值(主键或其他字段),那么你可以依赖changed``(Field<?> field, boolean changed)方法,如下所示:
record.changed(SALE.SALE_ID, false);
好吧,这些只是一些示例。更多示例,包括在 PostgreSQL 和 Oracle 中使用 UDTs(用户定义类型)以及在INSERT中使用用户定义函数,可以在名为InsertValues的应用程序捆绑代码中找到。接下来,让我们谈谈INSERT ... SET。
表达 INSERT ... SET
INSERT ... SET是INSERT ... VALUES的替代方案,具有类似UPDATE的语法,并且在 MySQL(但不仅限于此)中常用。实际上,在INSERT ... SET中,我们通过set(field, value)方法写入字段值对,这使得代码更易读,因为我们能轻松地识别每个字段的值。让我们看看插入两行数据的示例:
ctx.insertInto(SALE)
.set(SALE.FISCAL_YEAR, 2005) // first row
.set(SALE.SALE_, 4523.33)
.set(SALE.EMPLOYEE_NUMBER, 1504L)
.set(SALE.FISCAL_MONTH, 3)
.set(SALE.REVENUE_GROWTH, 12.22)
.newRecord()
.set(SALE.FISCAL_YEAR, 2005) // second row
.set(SALE.SALE_, 4523.33)
.set(SALE.EMPLOYEE_NUMBER, 1504L)
.set(SALE.FISCAL_MONTH, 4)
.set(SALE.REVENUE_GROWTH, 22.12)
.execute();
此语法也适用于Record:
SaleRecord sr = new SaleRecord(...);
ctx.insertInto(SALE).set(sr).execute();
由于INSERT … SET和INSERT … VALUES是等效的,jOOQ 将所有由 jOOQ 支持的数据库中的INSERT … SET模拟为INSERT … VALUES。完整的应用程序命名为InsertSet。接下来,让我们来处理INSERT ... RETURNING语法。
表达 INSERT ... RETURNING
INSERT ... RETURNING的特殊之处在于它可以返回所插入的内容(检索我们需要进一步处理的内容)。这可能包括返回插入行的主键或其他字段(例如,其他序列、默认生成的值和触发器结果)。PostgreSQL 原生支持INSERT ... RETURNING。Oracle 也支持INSERT ... RETURNING,jOOQ 会为其生成 PL/SQL 匿名块(不一定总是)。SQL Server 支持OUTPUT,这与它几乎相同(除了触发器生成的值如何受影响)。其他数据库支持较差,jOOQ 必须代表我们模拟它。在这种情况下,jOOQ 依赖于 JDBC 的getGeneratedKeys()方法来检索插入的主键。此外,如果生成的主键(或其他列)不能直接检索,jOOQ 可能需要执行额外的SELECT来达到这个目的,这可能导致竞争条件(例如,在 MySQL 中可能需要这样的SELECT)。
INSERT ... RETURNING的 jOOQ API 包含returningResult()方法,它有多种形式。它带有不同的参数列表,允许我们指定应返回哪些字段。如果应返回所有字段,则无需参数直接使用它。如果只返回主键(这是一个流行的用例,例如 MySQL 的AUTO_INCREMENT或 PostgreSQL 的(BIG)SERIAL,这些会自动生成序列),则只需指定为returningResult(pk_field)。如果主键有多个字段(复合主键),则列出所有字段,字段之间用逗号分隔。
这里是一个返回单个插入操作主键的示例:
// Record1<Long>
var insertedId = ctx.insertInto(SALE,
SALE.FISCAL_YEAR, SALE.SALE_, SALE.EMPLOYEE_NUMBER,
SALE.REVENUE_GROWTH, SALE.FISCAL_MONTH)
.values(2004, 2311.42, 1370L, 10.12, 1)
.returningResult(SALE.SALE_ID)
.fetchOne();
由于只有一个结果,我们通过fetchOne()方法获取它。可以通过fetch()方法获取多个主键,如下所示:
// Result<Record1<Long>>
var insertedIds = ctx.insertInto(SALE,
SALE.FISCAL_YEAR,SALE.SALE_, SALE.EMPLOYEE_NUMBER,
SALE.REVENUE_GROWTH, SALE.FISCAL_MONTH)
.values(2004, 2311.42, 1370L, 12.50, 1)
.values(2003, 900.21, 1504L, 23.99, 2)
.values(2005, 1232.2, 1166L, 14.65, 3)
.returningResult(SALE.SALE_ID)
.fetch();
这次,返回的结果包含三个主键。返回更多/其他字段可以按以下方式完成(结果看起来像一个n-列 x n-行的表):
// Result<Record2<String, LocalDate>>
var inserted = ctx.insertInto(PRODUCTLINE,
PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.TEXT_DESCRIPTION,
PRODUCTLINE.CODE)
.values(..., "This new line of electric vans ...", 983423L)
.values(..., "This new line of turbo N cars ...", 193384L)
.returningResult(PRODUCTLINE.PRODUCT_LINE,
PRODUCTLINE.CREATED_ON)
.fetch();
现在,让我们看看一个更有趣的例子。在我们的数据库模式中,CUSTOMER 和 CUSTOMERDETAIL 表是单向一对一关系,并共享主键值。换句话说,CUSTOMER 主键同时是 CUSTOMERDETAIL 中的主键和外键;这样,就没有必要维护单独的外键。因此,我们必须使用 CUSTOMER 返回的主键来插入 CUSTOMERDETAIL 表中相应的行:
ctx.insertInto(CUSTOMERDETAIL)
.values(ctx.insertInto(CUSTOMER)
.values(default_(), ..., "Kyle", "Doyle",
"+ 44 321 321", default_(), default_(), default_())
.returningResult(CUSTOMER.CUSTOMER_NUMBER).fetchOne()
.value1(), ..., default_(), "Los Angeles",
default_(), default_(), "USA")
.execute();
如果你熟悉 JPA,那么你在这里可以认出一个优雅的 @MapsId 的替代方案。
第一个 INSERT(内部 INSERT)将在 CUSTOMER 表中插入一行,并通过 returningResult() 返回生成的主键。接下来,第二个 INSERT(外部 INSERT)将使用此返回的主键作为 CUSTOMERDETAIL.CUSTOMER_NUMBER 主键的值,在 CUSTOMERDETAIL 表中插入一行。
此外,returningResult() 方法还可以返回表达式,例如 returningResult(A.concat(B).as("C"))。
如往常一样,花些时间检查附带代码,其中包含更多示例。应用程序命名为 InsertReturning。接下来,让我们谈谈 INSERT ... DEFAULT VALUES。
表达 INSERT ... DEFAULT VALUES
插入默认值的直接方法是省略 INSERT 中具有默认值的字段。例如,看看以下代码:
ctx.insertInto(PRODUCT)
.columns(PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_LINE,
PRODUCT.CODE, PRODUCT.PRODUCT_SCALE,
PRODUCT.PRODUCT_VENDOR, PRODUCT.BUY_PRICE,
PRODUCT.MSRP)
.values("Ultra Jet X1", "Planes", 433823L, "1:18",
"Motor City Art Classics",
BigDecimal.valueOf(45.9), BigDecimal.valueOf(67.9))
.execute();
未列出的 PRODUCT 字段(PRODUCT_DESCRIPTION、PRODUCT_UID、SPECS 和 QUANTITY_IN_STOCK)将利用隐式默认值。
jOOQ API 提供了 defaultValues()、defaultValue() 和 default_() 方法,用于显式指出应该依赖默认值的字段。前者用于插入只有默认值的单行;如果你检查数据库模式,你会注意到 MANAGER 表的每一列都有一个默认值:
ctx.insertInto(MANAGER).defaultValues().execute();
另一方面,defaultValue() 方法(或 default_())允许我们指向应该依赖默认值的字段:
ctx.insertInto(PRODUCT)
.columns(PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_LINE,
PRODUCT.CODE, PRODUCT.PRODUCT_SCALE,
PRODUCT.PRODUCT_VENDOR, PRODUCT.PRODUCT_DESCRIPTION,
PRODUCT.QUANTITY_IN_STOCK, PRODUCT.BUY_PRICE,
PRODUCT.MSRP, PRODUCT.SPECS, PRODUCT.PRODUCT_UID)
.values(val("Ultra Jet X1"), val("Planes"),
val(433823L),val("1:18"),
val("Motor City Art Classics"),
defaultValue(PRODUCT.PRODUCT_DESCRIPTION),
defaultValue(PRODUCT.QUANTITY_IN_STOCK),
val(BigDecimal.valueOf(45.99)),
val(BigDecimal.valueOf(67.99)),
defaultValue(PRODUCT.SPECS),
defaultValue(PRODUCT.PRODUCT_UID))
.execute();
此示例的非类型安全版本如下:
ctx.insertInto(PRODUCT)
.values(defaultValue(), "Ultra Jet X1", "Planes", 433823L,
defaultValue(), "Motor City Art Classics",
defaultValue(), defaultValue(), 45.99, 67.99,
defaultValue(), defaultValue())
.execute();
ctx.insertInto(PRODUCT)
.values(defaultValue(PRODUCT.PRODUCT_ID),
"Ultra JetX1", "Planes", 433823L,
defaultValue(PRODUCT.PRODUCT_SCALE),
"Motor City Art Classics",
defaultValue(PRODUCT.PRODUCT_DESCRIPTION),
defaultValue(PRODUCT.QUANTITY_IN_STOCK),
45.99, 67.99, defaultValue(PRODUCT.SPECS),
defaultValue(PRODUCT.PRODUCT_UID))
.execute()
通过指定列的类型也可以得到相同的结果。例如,之前的 defaultValue(PRODUCT.QUANTITY_IN_STOCK) 调用可以写成以下形式:
defaultValue(INTEGER) // or, defaultValue(Integer.class)
使用默认值插入 Record 可以非常简单,如下面的示例所示:
ctx.newRecord(MANAGER).insert();
ManagerRecord mr = new ManagerRecord();
ctx.newRecord(MANAGER, mr).insert();
使用默认值对于填充那些稍后将被更新(例如,通过后续更新、触发器生成的值等)的字段或如果我们根本没有值的情况非常有用。
完整的应用程序命名为 InsertDefaultValues。接下来,让我们谈谈 jOOQ 和 UPDATE 语句。
表达 UPDATE 语句
在本节中,我们将表达不同类型的更新,包括 UPDATE ... SET、UPDATE ... FROM 和 UPDATE ... RETURNING,并通过 jOOQ DSL 语法使用行值表达式进行更新。在撰写本文时,jOOQ 支持对单个表的更新,而对多个表的更新则是一个正在进行中的任务。
表达 UPDATE ... SET
直接的 UPDATE ... SET 语句可以通过 set(field, value) 方法在 jOOQ 中表达,如下面的例子所示(不要忘记调用 execute() 来触发更新):
ctx.update(OFFICE)
.set(OFFICE.CITY, "Banesti")
.set(OFFICE.COUNTRY, "Romania")
.where(OFFICE.OFFICE_CODE.eq("1"))
.execute();
生成的针对 MySQL 语言的 SQL 将如下所示:
UPDATE `classicmodels`.`office`
SET `classicmodels`.`office'.`city` = ?,
`classicmodels`.`office`.`country` = ?
WHERE `classicmodels`.`office`.`office_code` = ?
看起来像是一个经典的 UPDATE,对吧?注意 jOOQ 自动只渲染更新的列。如果你来自 JPA,那么你知道 Hibernate JPA 默认渲染所有列,而我们必须依赖于 @DynamicUpdate 来获得与 jOOQ 相同的效果。
查看一个基于员工销售额计算的增加员工工资的示例:
ctx.update(EMPLOYEE)
.set(EMPLOYEE.SALARY, EMPLOYEE.SALARY.plus(
field(select(count(SALE.SALE_).multiply(5.75)).from(SALE)
.where(EMPLOYEE.EMPLOYEE_NUMBER
.eq(SALE.EMPLOYEE_NUMBER)))))
.execute();
这里是针对 SQL Server 方言生成的 SQL:
UPDATE [classicmodels].[dbo].[employee]
SET [classicmodels].[dbo].[employee].[salary] =
([classicmodels].[dbo].[employee].[salary] +
(SELECT (count([classicmodels].[dbo].[sale].[sale]) * ?)
FROM [classicmodels].[dbo].[sale]
WHERE [classicmodels].[dbo].[employee].[employee_number]
= [classicmodels].[dbo].[sale].[employee_number]))
注意,这是一个没有 WHERE 子句的 UPDATE,jOOQ 会记录一条消息,例如 A statement is executed without WHERE clause。这只是友好的信息,如果你故意省略了 WHERE 子句,你可以忽略它。但是,如果你知道这不是故意为之,那么你可能想通过依赖 jOOQ 的 withExecuteUpdateWithoutWhere() 设置来避免这种情况。你可以从几种行为中选择,包括抛出异常,就像这个例子中那样:
ctx.configuration().derive(new Settings()
.withExecuteUpdateWithoutWhere(ExecuteWithoutWhere.THROW))
.dsl()
.update(OFFICE)
.set(OFFICE.CITY, "Banesti")
.set(OFFICE.COUNTRY, "Romania")
.execute();
注意,我们使用 Configuration.derive(),而不是 Configuration.set(),因为如果 DSLContext 被注入,Configuration 是全局的和共享的。使用 Configuration.set() 将会影响全局设置。如果这是期望的行为,那么最好依赖于一个单独的 @Bean,就像你在本书中已经看到的那样。
这次,无论何时我们尝试执行没有 WHERE 子句的 UPDATE,UPDATE 都不会采取任何行动,并且 jOOQ 会抛出一个 org.jooq.exception.DataAccessException 类型的异常。
更新 Record 也很简单。看看这个例子:
OfficeRecord or = new OfficeRecord();
or.setCity("Constanta");
or.setCountry("Romania");
ctx.update(OFFICE)
.set(or)
.where(OFFICE.OFFICE_CODE.eq("1")).execute();
// or, like this
ctx.executeUpdate(or, OFFICE.OFFICE_CODE.eq("1"));
正如你在捆绑的代码中所看到的,使用 DSLContext.newRecord() 也是一个选项。
使用行值表达式表达 UPDATE
使用行值表达式更新是一个非常实用的工具,jOOQ 以非常干净和直观的方式表达这样的更新。看看这个例子:
ctx.update(OFFICE)
.set(row(OFFICE.ADDRESS_LINE_FIRST,
OFFICE.ADDRESS_LINE_SECOND, OFFICE.PHONE),
select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
val("+40 0721 456 322"))
.from(EMPLOYEE)
.where(EMPLOYEE.JOB_TITLE.eq("President")))
.execute();
生成的针对 PostgreSQL 的 SQL 如下所示:
UPDATE "public"."office"
SET ("address_line_first", "address_line_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?)
即使行值表达式特别适用于编写子查询,就像前面的例子中那样,这并不意味着你不能编写以下内容:
ctx.update(OFFICE)
.set(row(OFFICE.CITY, OFFICE.COUNTRY),
row("Hamburg", "Germany"))
.where(OFFICE.OFFICE_CODE.eq("1"))
.execute();
这对于重用具有最小冗余的字段很有用:
Row2<String, String> r1 = row(OFFICE.CITY, OFFICE.COUNTRY);
Row2<String, String> r2 = row("Hamburg", "Germany");
ctx.update(OFFICE).set(r1, r2).where(r1.isNull())
.execute();
接下来,让我们来处理 UPDATE ... FROM 语法。
表达 UPDATE ... FROM
使用UPDATE ... FROM语法,我们可以将额外的表连接到UPDATE语句。请注意,这个FROM子句在 PostgreSQL 和 SQL Server 中是供应商特定的支持,但在 MySQL 和 Oracle 中不受支持(然而,当你阅读这本书时,jOOQ 可能已经模拟了这个语法,所以请查看)。以下是一个示例:
ctx.update(PRODUCT)
.set(PRODUCT.BUY_PRICE, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(PRODUCT.PRODUCT_ID.eq(ORDERDETAIL.PRODUCT_ID))
.execute();
下面的示例展示了为 PostgreSQL 渲染的 SQL:
UPDATE "public"."product"
SET "buy_price" = "public"."orderdetail"."price_each"
FROM "public"."orderdetail"
WHERE "public"."product"."product_id"
= "public"."orderdetail"."product_id"
最后,让我们来处理UPDATE ... RETURNING语法。
表达 UPDATE ... RETURNING
UPDATE ... RETURNING类似于INSERT ... RETURNING,但用于UPDATE。这是 PostgreSQL 原生支持的,并由 jOOQ 在 SQL Server 和 Oracle 中模拟。在 jOOQ DSL 中,我们通过returningResult()来表示UPDATE ... RETURNING,如下面的示例所示:
ctx.update(OFFICE)
.set(OFFICE.CITY, "Paris")
.set(OFFICE.COUNTRY, "France")
.where(OFFICE.OFFICE_CODE.eq("1"))
.returningResult(OFFICE.CITY, OFFICE.COUNTRY)
.fetchOne();
下面的示例展示了为 PostgreSQL 渲染的 SQL:
UPDATE "public"."office"
SET "city" = ?,
"country" = ?
WHERE "public"."office"."office_code" = ?
RETURNING "public"."office"."city",
"public"."office"."country"
我们可以使用UPDATE ... RETURNING来逻辑上链接多个更新。例如,假设我们想根据员工的平均销售额和客户的信用额度增加员工的薪水,并将返回的薪水乘以两倍。我们可以通过以下方式流畅地表达这两个UPDATE语句:
ctx.update(CUSTOMER)
.set(CUSTOMER.CREDIT_LIMIT, CUSTOMER.CREDIT_LIMIT.plus(
ctx.update(EMPLOYEE)
.set(EMPLOYEE.SALARY, EMPLOYEE.SALARY.plus(
field(select(avg(SALE.SALE_)).from(SALE)
.where(SALE.EMPLOYEE_NUMBER
.eq(EMPLOYEE.EMPLOYEE_NUMBER)))))
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1504L))
.returningResult(EMPLOYEE.SALARY
.coerce(BigDecimal.class))
.fetchOne().value1()
.multiply(BigDecimal.valueOf(2))))
.where(CUSTOMER.SALES_REP_EMPLOYEE_NUMBER.eq(1504L))
.execute();
然而,请注意潜在的竞争条件,因为在看似单个查询中隐藏了两次往返。
通过名为UpdateSamples的应用程序练习这个示例和其他许多示例。接下来,让我们来处理DELETE语句。
表达 DELETE 语句
在 jOOQ 中表达DELETE语句可以通过DSLContext.delete()和DSLContext.deleteFrom() API,或者通过DSLContext.deleteQuery()和DSLContext.executeDelete()来实现。虽然前三种方法接收Table<R>类型的参数,但executeDelete()方法对于将记录作为TableRecord<?>或UpdatableRecord<?>删除非常有用。正如以下示例所示,delete()和deleteFrom()的工作方式完全相同:
ctx.delete(SALE)
.where(SALE.FISCAL_YEAR.eq(2003))
.execute();
ctx.deleteFrom(SALE)
.where(SALE.FISCAL_YEAR.eq(2003))
.execute();
这两个表达式都渲染出以下 SQL:
DELETE FROM `classicmodels`.`sale`
WHERE `classicmodels`.`sale`.`fiscal_year` = ?
将DELETE和行值表达式结合使用对于通过子查询删除非常有用,如下面的示例所示:
ctx.deleteFrom(CUSTOMERDETAIL)
.where(row(CUSTOMERDETAIL.POSTAL_CODE,
CUSTOMERDETAIL.STATE).in(
select(OFFICE.POSTAL_CODE, OFFICE.STATE)
.from(OFFICE)
.where(OFFICE.COUNTRY.eq("USA"))))
.execute();
DELETE的一个重要方面是从父表到子表的级联删除。在可能的情况下,依靠数据库支持来完成DELETE级联任务是一个好主意。例如,你可以使用ON DELETE CASCADE或实现级联删除逻辑的存储过程。正如 Lukas Eder 强调的,“一般来说,应该级联组合(UML 术语)和限制或无操作,或者设置为 NULL(如果支持)聚合。换句话说,如果子表不能没有父表(组合)而存在,那么就与父表一起删除它。否则,抛出异常(限制、无操作),或将引用设置为NULL。jOOQ 可能在将来支持DELETE ... CASCADE:github.com/jOOQ/jOOQ/issues/7367。
然而,如果上述任何方法都不可行,那么您也可以通过 jOOQ 来完成。您可以编写一系列单独的 DELETE 语句,或者依赖于 DELETE ... RETURNING,如下面的示例所示,这些示例通过级联(PRODUCTLINE – PRODUCTLINEDETAIL – PRODUCT – ORDERDETAIL)删除 PRODUCTLINE。为了删除 PRODUCTLINE,我们必须从 PRODUCT 中删除所有其产品,并从 PRODUCTLINEDETAIL 中删除相应的记录。为了从 PRODUCT 中删除 PRODUCTLINE 的所有产品,我们必须删除这些产品的所有引用从 ORDERDETAIL 中。因此,我们从 ORDERDETAIL 开始删除,如下所示:
ctx.delete(PRODUCTLINE)
.where(PRODUCTLINE.PRODUCT_LINE.in(
ctx.delete(PRODUCTLINEDETAIL)
.where(PRODUCTLINEDETAIL.PRODUCT_LINE.in(
ctx.delete(PRODUCT)
.where(PRODUCT.PRODUCT_ID.in(
ctx.delete(ORDERDETAIL)
.where(ORDERDETAIL.PRODUCT_ID.in(
select(PRODUCT.PRODUCT_ID).from(PRODUCT)
.where(PRODUCT.PRODUCT_LINE.eq("Motorcycles")
.or(PRODUCT.PRODUCT_LINE
.eq("Trucks and Buses")))))
.returningResult(ORDERDETAIL.PRODUCT_ID).fetch()))
.returningResult(PRODUCT.PRODUCT_LINE).fetch()))
.returningResult(PRODUCTLINEDETAIL.PRODUCT_LINE).fetch()))
.execute();
这个 jOOQ 流畅表达式生成了四个 DELETE 语句,您可以在捆绑的代码中检查。这里的挑战在于确保在出错时能够保证回滚功能。但是,在 Spring Boot @Transactional 方法中拥有 jOOQ 表达式,回滚功能是现成的。这比通过 CascadeType.REMOVE 或 orphanRemoval=true 的 JPA 级联要好得多,这些方法非常容易引发 N + 1 问题。jOOQ 允许我们控制要删除的内容以及如何进行删除。
在其他方面,删除 Record (TableRecord 或 UpdatableRecord) 可以通过 executeDelete() 来完成,如下面的示例所示:
PaymentRecord pr = new PaymentRecord();
pr.setCustomerNumber(114L);
pr.setCheckNumber("GG31455");
...
// jOOQ render a WHERE clause based on the record PK
ctx.executeDelete(pr);
// jOOQ render our explicit Condition
ctx.executeDelete(pr,
PAYMENT.INVOICE_AMOUNT.eq(BigDecimal.ZERO));
正如在 UPDATE 的情况下,如果我们尝试在没有 WHERE 子句的情况下执行 DELETE,那么 jOOQ 将通过一条友好的消息通知我们。我们可以通过 withExecuteDeleteWithoutWhere() 设置来控制在这种情况下应该发生什么。
在捆绑的代码中,您可以看到 withExecuteDeleteWithoutWhere() 在许多其他未在此列出的示例旁边。整个应用程序被命名为 DeleteSamples。接下来,让我们来谈谈 MERGE 语句。
表达 MERGE 语句
MERGE 语句是一个非常强大的工具;它允许我们从称为 目标表 的表执行 INSERT/UPDATE 以及甚至 DELETE 操作,这个表是从称为 源表 的表中来的。我强烈建议您阅读这篇文章,尤其是如果您需要快速回顾 MERGE 语句的话:blog.jooq.org/2020/04/10/the-many-flavours-of-the-arcane-sql-merge-statement/.
MySQL 和 PostgreSQL 通过 ON DUPLICATE KEY UPDATE(MySQL)和 ON CONFLICT DO UPDATE(PostgreSQL)子句分别支持一个名为 UPSERT 的 MERGE 风味(INSERT 或 UPDATE)。您可以在本书捆绑的代码中找到这些语句的示例,这些示例位于众所周知的 INSERT IGNORE INTO(MySQL)和 ON CONFLICT DO NOTHING(PostgreSQL)子句旁边。顺便说一句,我们可以互换使用所有这些语句(例如,我们可以使用 onConflictDoNothing() 与 MySQL,使用 onDuplicateKeyIgnore() 与 PostgreSQL),因为 jOOQ 总是会模拟正确的语法。我们甚至可以使用 SQL Server 和 Oracle,因为 jOOQ 会通过 MERGE INTO 语法来模拟它们。
SQL Server 和 Oracle 支持 MERGE INTO 与不同的附加子句。以下是一个利用 WHEN MATCHED THEN UPDATE(jOOQ whenMatchedThenUpdate())和 WHEN NOT MATCHED THEN INSERT(jOOQ whenNotMatchedThenInsert())子句的示例:
ctx.mergeInto(PRODUCT)
.usingDual() // or, (ctx.selectOne())
.on(PRODUCT.PRODUCT_NAME.eq("1952 Alpine Renault 1300"))
.whenMatchedThenUpdate()
.set(PRODUCT.PRODUCT_NAME, "1952 Alpine Renault 1600")
.whenNotMatchedThenInsert(
PRODUCT.PRODUCT_NAME, PRODUCT.CODE)
.values("1952 Alpine Renault 1600", 599302L)
.execute();
SQL Server 方言的渲染 SQL 如下所示:
MERGE INTO [classicmodels].[dbo].[product] USING
(SELECT 1 [one]) t ON
[classicmodels].[dbo].[product].[product_name] = ?
WHEN MATCHED THEN
UPDATE
SET [classicmodels].[dbo].[product].[product_name] = ?
WHEN NOT MATCHED THEN
INSERT ([product_name], [code])
VALUES (?, ?);
现在,让我们看看另一个使用 WHEN MATCHED THEN DELETE(jOOQ whenMatchedThenDelete())和 WHEN NOT MATCHED THEN INSERT(jOOQ whenNotMatchedThenInsert())子句的示例:
ctx.mergeInto(SALE)
.using(EMPLOYEE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
.whenMatchedThenDelete()
.whenNotMatchedThenInsert(SALE.EMPLOYEE_NUMBER,
SALE.FISCAL_YEAR, SALE.SALE_,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values(EMPLOYEE.EMPLOYEE_NUMBER, val(2015),
coalesce(val(-1.0).mul(EMPLOYEE.COMMISSION), val(0.0)),
val(1), val(0.0))
.execute();
这在 SQL Server 中工作得非常完美,但在 Oracle 中不起作用,因为 Oracle 不支持 WHEN MATCHED THEN DELETE 子句。但是,我们可以通过将 WHEN MATCHED THEN UPDATE 与 DELETE WHERE(通过 jOOQ 的 thenDelete() 获得)子句结合起来轻松地获得相同的结果。这是因为,在 Oracle 中,您可以添加 DELETE WHERE 子句,但只能与 UPDATE 一起使用:
ctx.mergeInto(SALE)
.using(EMPLOYEE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
// .whenMatchedThenDelete() - not supported by Oracle
.whenMatchedAnd(selectOne().asField().eq(1))
.thenDelete()
.whenNotMatchedThenInsert(SALE.EMPLOYEE_NUMBER,
SALE.FISCAL_YEAR, SALE.SALE_,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values(EMPLOYEE.EMPLOYEE_NUMBER, val(2015),
coalesce(val(-1.0).mul(EMPLOYEE.COMMISSION), val(0.0)),
val(1), val(0.0))
.execute();
WHEN MATCHED THEN UPDATE 是通过 jOOQ 的 whenMatchedAnd() 获得的;这是 jOOQ 对 WHEN MATCHED AND <some predicate> THEN 子句的实现,但在此情况下,它被表示为 WHEN MATCHED THEN UPDATE。
在 SQL Server 和 Oracle 中使用 DELETE WHERE 子句的效果相同。使用 DELETE WHERE 子句的一个重要方面是它引用的是哪个表。此子句可以针对更新前或更新后的行。以下 MERGE 示例更新了目标表中所有在源表中具有匹配行的行。DELETE WHERE 子句仅删除那些由 UPDATE 匹配的行(这是 UPDATE 之后的 DELETE):
ctx.mergeInto(SALE)
.using(EMPLOYEE)
.on(SALE.EMPLOYEE_NUMBER.eq(EMPLOYEE.EMPLOYEE_NUMBER))
.whenMatchedThenUpdate()
.set(SALE.SALE_, coalesce(SALE.SALE_
.minus(EMPLOYEE.COMMISSION), SALE.SALE_))
.deleteWhere(SALE.SALE_.lt(1000.0))
.execute();
以下示例显示 DELETE WHERE 可以匹配更新之前的行的值。这次,DELETE WHERE 引用了 源表,因此状态是针对源而不是针对 UPDATE 的结果进行检查(这是 UPDATE 之前的 DELETE):
ctx.mergeInto(SALE)
.using(EMPLOYEE)
.on(SALE.EMPLOYEE_NUMBER.eq(EMPLOYEE.EMPLOYEE_NUMBER))
.whenMatchedThenUpdate()
.set(SALE.SALE_, coalesce(SALE.SALE_
.minus(EMPLOYEE.COMMISSION), SALE.SALE_))
.deleteWhere(EMPLOYEE.COMMISSION.lt(1000))
.execute();
在捆绑的代码中,您可以练习更多示例。应用程序命名为 MergeSamples。
摘要
本章是表达 jOOQ DSL 语法中流行的 SELECT、INSERT、UPDATE、DELETE 和 MERGE 语句的全面资源,依赖于基于 Java 的模式。
为了简洁,我们无法在此列出所有示例,但我强烈建议您针对您最喜欢的数据库对每个应用程序进行练习。主要目标是让您熟悉 jOOQ 语法,并能够在短时间内通过 jOOQ API 表达任何 plain SQL。
在下一章中,我们将继续这个冒险,探讨一个非常激动人心的主题:在 jOOQ 中表达 JOIN。
第六章:处理不同类型的 JOIN
SQL 的 JOIN 子句代表了最常用的 SQL 功能之一。从众所周知的 INNER 和 OUTER JOIN 子句,到虚构的半和反 JOIN,再到花哨的 LATERAL JOIN,本章提供了一套全面的示例,旨在帮助您通过 jOOQ DSL API 练习各种 JOIN 子句。
本章的主题包括以下内容:
-
练习最流行的 JOIN 类型(
CROSS、INNER和OUTER) -
SQL 的
USING和 jOOQ 的onKey()快捷方式 -
练习更多类型的
JOINs(隐式、自身、NATURAL、STRAIGHT、半、反和LATERAL)
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter06。
练习最流行的 JOIN 类型
通过最流行的 JOIN 语句类型,我们指的是 CROSS JOIN、INNER JOIN、LEFT JOIN、RIGHT JOIN 和 FULL JOIN。让我们通过 jOOQ DSL API 来解决每个问题,从最基本的 JOIN 类型开始。
CROSS JOIN
CROSS JOIN 是最基本的 JOIN 类型,它在笛卡尔积中实现。有两个表 A 和 B,它们之间的 CROSS JOIN 操作表示为 A x B,实际上,这意味着 A 表的每一行与 B 表的每一行的组合。
在 jOOQ 中,CROSS JOIN 可以通过在 FROM 子句中列出表(非 -ANSI JOIN 语法)或通过 crossJoin() 方法来渲染,该方法渲染 CROSS JOIN 关键字(ANSI JOIN 语法)。以下是第一种情况——让我们将 OFFICE 和 DEPARTMENT 表进行 CROSS JOIN:
ctx.select().from(OFFICE, DEPARTMENT).fetch();
由于此查询没有明确或清晰地表达其使用 CROSS JOIN 的意图,因此它不如以下使用 jOOQ crossJoin() 方法的查询友好:
ctx.select().from(OFFICE).crossJoin(DEPARTMENT).fetch();
使用 crossJoin() 方法渲染 CROSS JOIN 关键字(ANSI JOIN 语法),这清楚地传达了我们的意图,并消除了任何潜在的混淆:
SELECT `classicmodels`.`office`.`office_code`,
`classicmodels`.`office`.`city`,
...
`classicmodels`.`department`.`department_id`,
`classicmodels`.`department`.`name`,
...
FROM `classicmodels`.`office`
CROSS JOIN `classicmodels`.`department`
由于一些办公室的 CITY 和/或 COUNTRY 列具有 NULL 值,我们可以通过谓词轻松排除它们从 OFFICE x DEPARTMENT 中。此外,仅为了好玩,我们可能更喜欢将结果连接为 城市,国家:部门(例如,旧金山,美国:广告):
ctx.select(concat(OFFICE.CITY, inline(", "), OFFICE.COUNTRY,
inline(": "), DEPARTMENT.NAME).as("offices"))
.from(OFFICE).crossJoin(DEPARTMENT)
.where(row(OFFICE.CITY, OFFICE.COUNTRY).isNotNull())
.fetch();
基本上,一旦我们添加了一个谓词,这就会变成 INNER JOIN,正如以下章节所讨论的。更多示例可以在捆绑的代码中的 CrossJoin 中找到。
INNER JOIN
INNER JOIN(或简称 JOIN)表示通过某些谓词进行筛选的笛卡尔积,这些谓词通常放在 ON 子句中。因此,对于 A 和 B 表,INNER JOIN 返回满足指定谓词的 A x B 的行。
在 jOOQ 中,我们通过innerJoin()(如果您的数据库供应商支持省略INNER,则可以简单地使用join())和on()方法渲染INNER JOIN。以下是一个在EMPLOYEE和OFFICE之间应用INNER JOIN以获取员工姓名及其办公室城市的示例:
ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, OFFICE.CITY)
.from(EMPLOYEE)
.innerJoin(OFFICE)
.on(EMPLOYEE.OFFICE_CODE.eq(OFFICE.OFFICE_CODE))
.fetch();
对于 MySQL 方言的渲染 SQL 如下:
SELECT `classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`,
`classicmodels`.`office`.`city`
FROM `classicmodels`.`employee`
JOIN `classicmodels`.`office` ON
`classicmodels`.`employee`.'office_code'
= `classicmodels`.`office`.`office_code`
默认情况下,jOOQ 不会渲染可选的INNER关键字。但是,您可以通过withRenderOptionalInnerKeyword()设置和RenderOptionalKeyword.ON参数来更改此默认设置。
在 jOOQ 中,链式调用多个JOIN操作相当简单。例如,获取经理及其办公室需要两个INNER JOIN子句,因为MANAGER和OFFICE之间有一个多对多关系,由MANAGER_HAS_OFFICE连接表映射:
ctx.select()
.from(MANAGER)
.innerJoin(OFFICE_HAS_MANAGER)
.on(MANAGER.MANAGER_ID
.eq(OFFICE_HAS_MANAGER.MANAGERS_MANAGER_ID))
.innerJoin(OFFICE)
.on(OFFICE.OFFICE_CODE
.eq(OFFICE_HAS_MANAGER.OFFICES_OFFICE_CODE))
.fetch();
在这些示例中,我们在org.jooq.SelectFromStep上调用 jOOQ 的连接方法,对于 PostgreSQL 方言的渲染 SQL 如下:
…
FROM
"public"."manager"
JOIN "public"."office_has_manager"
ON "public"."manager"."manager_id" =
"public"."office_has_manager"."managers_manager_id"
JOIN "public"."office"
ON "public"."office"."office_code" =
"public"."office_has_manager"."offices_office_code"
但是,为了方便起见,我们可以在org.jooq.Table的FROM子句之后直接调用连接方法。在这种情况下,我们得到以下嵌套流畅代码(请随意使用您认为最方便的方法):
ctx.select()
.from(MANAGER
.innerJoin(OFFICE_HAS_MANAGER
.innerJoin(OFFICE)
.on(OFFICE.OFFICE_CODE.eq(
OFFICE_HAS_MANAGER.OFFICES_OFFICE_CODE)))
.on(MANAGER.MANAGER_ID.eq(
OFFICE_HAS_MANAGER.MANAGERS_MANAGER_ID)))
.fetch();
对于 PostgreSQL 方言的渲染 SQL 如下:
…
FROM
"public"."manager"
JOIN
(
"public"."office_has_manager"
JOIN "public"."office"
ON "public"."office"."office_code" =
"public"."office_has_manager"."offices_office_code"
) ON "public"."manager"."manager_id" =
"public"."office_has_manager"."managers_manager_id"
接下来,让我们谈谈OUTER JOIN。
外连接
虽然INNER JOIN只返回通过ON谓词的组合,但OUTER JOIN还会获取在连接操作的左侧(LEFT【外】JOIN)或右侧(RIGHT【外】JOIN)没有匹配的行。当然,我们还要提到FULL【外】JOIN。这会获取连接操作两边的所有行。
jOOQ API 通过leftOuterJoin()、rightOuterJoin()和fullOuterJoin()渲染OUTER JOIN。由于OUTER关键字是可选的,我们可以通过类似的方法省略它,即leftJoin()、rightJoin()和fullJoin()。
例如,让我们获取所有员工(左侧)及其销售(右侧)。通过使用LEFT【外】JOIN,我们可以保留所有员工,即使他们没有销售:
ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, SALE.SALE_)
.from(EMPLOYEE)
.leftOuterJoin(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
.fetch();
如果我们只想保留没有销售的员工,则可以通过添加一个排除所有匹配项的WHERE子句来依赖一个专用的LEFT【外】JOIN:
ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, SALE.SALE_)
.from(EMPLOYEE)
.leftOuterJoin(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
.where(SALE.EMPLOYEE_NUMBER.isNull())
.fetch();
对于 SQL Server 方言的渲染 SQL 如下:
SELECT
[classicmodels].[dbo].[employee].[first_name],
[classicmodels].[dbo].[employee].[last_name],
[classicmodels].[dbo].[sale].[sale]
FROM
[classicmodels].[dbo].[employee]
LEFT OUTER JOIN
[classicmodels].[dbo].[sale]
ON [classicmodels].[dbo].[employee].[employee_number] =
[classicmodels].[dbo].[sale].[employee_number]
WHERE [classicmodels].[dbo].[sale].[employee_number] IS NULL
如果您更喜欢使用 Oracle 的+符号简写来执行OUTER JOIN,请查看以下LEFT [OUTER] JOIN的示例:
ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, SALE.SALE_)
.from(EMPLOYEE, SALE)
.where(SALE.EMPLOYEE_NUMBER.plus()
.eq(EMPLOYEE.EMPLOYEE_NUMBER))
.fetch();
并且,Oracle SQL 如下:
SELECT
"CLASSICMODELS"."EMPLOYEE"."FIRST_NAME",
"CLASSICMODELS"."EMPLOYEE"."LAST_NAME",
"CLASSICMODELS"."SALE"."SALE"
FROM
"CLASSICMODELS"."EMPLOYEE",
"CLASSICMODELS"."SALE"
WHERE
"CLASSICMODELS"."SALE"."EMPLOYEE_NUMBER"(+) =
"CLASSICMODELS"."EMPLOYEE"."EMPLOYEE_NUMBER"
默认情况下,jOOQ 为leftOuterJoin()和leftJoin()都渲染可选的OUTER关键字。通过withRenderOptionalOuterKeyword()设置和RenderOptionalKeyword.ON参数来更改此默认设置。
在捆绑的代码中,您可以练习更多示例,包括RIGHT/FULL【外】JOIN。对于不支持FULL【外】JOIN的 MySQL,我们基于UNION子句编写了一些模拟代码。
重要提示
OUTER JOIN的一个特殊情况是 Oracle 的分区OUTER JOIN。
分区外连接
Oracle 的分区外部连接是OUTER JOIN的一个特殊情况。这种连接代表了对经典OUTER JOIN语法的扩展,并应用于通过PARTITION BY子句中的表达式定义的每个逻辑分区。分区外部连接返回分区表(逻辑分区)中每个分区的外部连接与连接另一侧的表的UNION。
分区外部连接是 Oracle 特有的,它们允许我们使用相当方便的语法和高效的执行计划来执行相同的“密集化”(填充稀疏数据)操作。
Oracle 分区外部连接可以使用的经典场景听起来是这样的:编写一个查询,返回每个员工(销售代表)在每个财政年度的销售情况,同时考虑到一些员工在某些年份没有销售——用 0 填充稀疏数据中的空白。例如,如果我们尝试通过简单的JOIN查看所有员工(销售代表)按财政年度分组的销售情况,那么我们会在以下图中获得一些数据空白:

图 6.1 – 填充稀疏数据中的空白
在图(a)中是我们容易从简单的JOIN中得到的,而在图(b)中是我们计划得到的。因此,我们希望看到所有的销售代表,即使他们在某些年份没有销售。这是 Oracle 分区外部连接的工作,其中逻辑分区是FISCAL_YEAR:
ctx.select(SALE.FISCAL_YEAR,
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
sum(nvl(SALE.SALE_, 0.0d)).as("SALES"))
.from(EMPLOYEE)
.leftOuterJoin(SALE).partitionBy(SALE.FISCAL_YEAR)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
.where(EMPLOYEE.JOB_TITLE.eq("Sales Rep"))
.groupBy(SALE.FISCAL_YEAR,
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.orderBy(1, 2)
.fetch();
当然,你可以不使用分区的外部连接来表示/模拟这个查询,但为此你必须查看PartitionedOuterJoin应用程序。
SQL 的 USING 和 jOOQ onKey()快捷方式
到目前为止,我们已经涵盖了在日常工作中最常用的典型JOIN。在我们继续介绍更多类型的JOIN之前,让我们介绍两个方便的快捷方式,这些快捷方式对于表达更简洁的JOIN非常有用。
SQL JOIN … USING
在某些情况下,SQL 的JOIN … USING子句可以是一个方便的替代经典JOIN … ON子句的选择。我们不需要在JOIN … ON子句中指定条件,而是将JOIN … USING子句列入一组字段(列)中,这些字段的名称是两个表共有的——JOIN操作的左侧表和右侧表。在 jOOQ 中,USING子句通过using()方法实现,如下面的示例所示。using()中提到的EMPLOYEE_NUMBER列是EMPLOYEE表的主键,也是SALE表的外键:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
SALE.SALE_)
.from(EMPLOYEE)
.innerJoin(SALE)
.using(EMPLOYEE.EMPLOYEE_NUMBER)
.fetch();
因此,using(EMPLOYEE.EMPLOYEE_NUMBER)是on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))的一个更简洁的表达,MySQL 方言的渲染 SQL 如下:
SELECT `classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`,
`classicmodels`.`sale`.`sale`
FROM `classicmodels`.`employee`
JOIN `classicmodels`.`sale` USING (`employee_number`)
但我们可以使用任何其他字段。以下是用于组合主键的USING子句:
...using(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.CODE)
或者,这是一个用于两个非主键/外键字段的USING子句:
.using(OFFICE.CITY, OFFICE.COUNTRY)
注意,没有参数的 using() 会渲染 ON TRUE,因此不会对连接操作应用任何过滤器。通过捆绑的 JoinUsing 应用程序练习完整的示例。接下来,让我们介绍 jOOQ 中的一个非常有用的工具,名为 onKey()。
然而,正如我所说的,USING 只适用于某些情况。Lukas Eder 强调了这一点:“USING 子句在查询变得复杂时会导致维护查询变得更加困难,因此通常不推荐使用。它在类型安全性方面较差(在 jOOQ 中)。当你重命名一个列时,你的 jOOQ 代码可能仍然可以编译。如果你使用的是 ON,则不会这样。当你添加一个意外匹配 USING 中引用的列的新列时,你可能会在不相关的查询中得到意外的后果。例如,A JOIN B USING (X) JOIN C USING (Y)。这假设了A(X)、B(X, Y)、C(Y)。那么,如果你添加A(Y)会发生什么?会抛出一个运行时异常,因为Y现在变得模糊不清。或者,更糟糕的是:如果你添加A(Y)但移除B(Y)*会发生什么?不会抛出运行时异常,但可能(并且悄无声息地)得到错误的查询。此外,在 Oracle 中,从 USING 引用的列不能再在查询中进行限定。总之,USING 可以用于快速且简单的即席查询,就像 NATURAL 一样。但我不建议在生产查询中使用它。特别是,因为隐式连接在 jOOQ 中工作得更好。”
“这里的本质始终是事实(这一点经常被误解),连接是两个表之间的 二元 操作符。例如,A JOIN B USING (X) JOIN C USING (Y) 简短地等同于 (A JOIN B USING (X)) JOIN C USING (Y),因此 C 是连接到 (A JOIN B USING (X)) 而不是单独连接到 B。这也适用于 onKey()。”
jOOQ onKey()
每当我们连接一个已知的 foreign key 关系时,我们都可以依赖 jOOQ 的 onKey() 方法。由于这对于简单的 foreign key 来说很容易理解,让我们选择一个包含两个字段的复合 foreign key。查看以下 ON 子句:
ctx.select(...)
.from(PAYMENT)
.innerJoin(BANK_TRANSACTION)
.on(PAYMENT.CUSTOMER_NUMBER.eq(
BANK_TRANSACTION.CUSTOMER_NUMBER)
.and(PAYMENT.CHECK_NUMBER.eq(
BANK_TRANSACTION.CHECK_NUMBER)))
(CUSTOMER_NUMBER, CHECK_NUMBER) 代表 BANK_TRANSACTION 表中的一个复合外键。jOOQ 允许我们使用不带参数的 onKey() 方法替换这个冗长的 ON 子句,如下所示:
ctx.select(...)
.from(PAYMENT)
.innerJoin(BANK_TRANSACTION)
.onKey()
.fetch();
真的很酷,不是吗?jOOQ 代表我们推断 ON 条件,渲染的 MySQL SQL 如下所示:
SELECT ...
FROM `classicmodels`.`payment`
JOIN `classicmodels`.`bank_transaction`
ON (`classicmodels`.`bank_transaction`.`customer_number`
= `classicmodels`.`payment`.`customer_number`
AND `classicmodels`.`bank_transaction`.`check_number`
= `classicmodels`.`payment`.`check_number`)
在多个键的潜在匹配引起的歧义情况下,我们也可以通过 onKey(TableField<?,?>... tfs) 使用外键的字段引用,或者通过生成的外键引用 onKey(ForeignKey<?,?> fk)。例如,为了避免在通过 onKey() 连接表 X 和表 Y 时出现的 DataAccessException:“X 和 Y 表之间的键模糊” 异常,我们可以明确指出应使用的外键如下(在这里,通过 SQL Server 生成的外键引用 jooq.generated.Keys.PRODUCTLINEDETAIL_PRODUCTLINE_FK):
ctx.select(…)
.from(PRODUCTLINE)
.innerJoin(PRODUCTLINEDETAIL)
.onKey(PRODUCTLINEDETAIL_PRODUCTLINE_FK)
.fetch();
这次,渲染的 SQL 如下所示:
SELECT ...
FROM [classicmodels].[dbo].[productline]
JOIN
[classicmodels].[dbo].[productlinedetail]
ON
([classicmodels].[dbo].[productlinedetail].[product_line] =
[classicmodels].[dbo].[productline].[product_line]
AND
[classicmodels].[dbo].[productlinedetail].[code] =
[classicmodels].[dbo].[productline].[code])
但尽管这种方法很有吸引力,它可能会导致问题。正如卢卡斯·埃德尔在这里分享的:“onKey() 方法不是类型安全的,当表被修改时,可能会以微妙的方式出错。”
在名为 JoinOnKey 的应用程序中提供了更多示例。现在,让我们继续探讨更多类型的 JOIN。
练习更多类型的 JOIN
接下来,让我们探讨更多 JOIN 类型,例如隐式/自连接、NATURAL JOIN、STRAIGHT JOIN、半/反连接和 LATERAL 连接。让我们继续探讨隐式/自连接。
隐式和自连接
通过 jOOQ 生成器在镜像数据库表的类中产生的类型安全导航方法,隐式连接和自连接可以很容易地在 jOOQ 中表达。让我们分析隐式连接的这一方面。
隐式连接
例如,一个显式连接,它从给定的子表中检索父表的列,可以表示为一个隐式连接。以下是显式连接:
SELECT o.office_code, e.first_name, e.last_name
FROM employee AS e
JOIN office AS o ON e.office_code = o.office_code
这里是更简洁的隐式连接版本:
SELECT e.office.office_code, e.first_name, e.last_name
FROM employee AS e
如果我们检查生成的基于 Java 的模式,那么我们会注意到镜像 EMPLOYEE 表的 jooq.generated.tables.Employee 类包含一个名为 office() 的方法,专门用于表达这种语法。以下是之前的隐式连接,通过 jOOQ DSL API 编写:
ctx.select(EMPLOYEE.office().OFFICE_CODE,
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.fetch();
这里是另一个示例,它通过链式调用几个导航方法来表达隐式连接,从 ORDERDETAIL 表开始:
ctx.select(
ORDERDETAIL.order().customer().employee().OFFICE_CODE,
ORDERDETAIL.order().customer().CUSTOMER_NAME,
ORDERDETAIL.order().SHIPPED_DATE,
ORDERDETAIL.order().STATUS,
ORDERDETAIL.QUANTITY_ORDERED, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.orderBy(ORDERDETAIL.order().customer().CUSTOMER_NAME)
.fetch();
这些导航方法的名称与父表名称相对应。以下是一个在多对多关系(m:n)中编写隐式连接的另一个示例。如果我们从关系表考虑 m:n 关系,那么我们会看到两个一对一关系,我们可以如下利用它们(在 MANAGER 和 OFFICE 之间存在多对多关系):
ctx.select(OFFICE_HAS_MANAGER.manager().fields())
.from(OFFICE_HAS_MANAGER)
.where(OFFICE_HAS_MANAGER.office().OFFICE_CODE.eq("6"))
.fetch();
注意,本节中介绍的隐式连接是基于 外键路径 的。很可能你也熟悉隐式连接,其中你在 FROM 子句中列出所有想要从中获取数据的表,然后是 WHERE 子句,它基于主键/外键值的条件来过滤结果。以下是一个此类隐式连接的 jOOQ 代码示例:
ctx.select(OFFICE.OFFICE_CODE,
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(OFFICE, EMPLOYEE)
.where(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE))
.orderBy(OFFICE.OFFICE_CODE)
.fetch();
注意
然而,请注意,这类隐式连接很容易出错,最好通过显式使用 JOIN 关键字依赖 ANSI JOIN 语法。让我利用这个上下文来说明,无论何时你有应该更新为 ANSI JOIN 的旧代码,你都可以依赖 jOOQ。除了 jOOQ DSL API 之外,你可以查看 www.jooq.org/translate,并且为了快速而简洁的指南,阅读这篇文章:blog.jooq.org/2020/11/17/automatically-transform-oracle-style-implicit-joins-to-ansi-join-using-jooq/。
在没有在模式中明确的外键(包括表实际上是视图的任何原因)的情况下,商业版用户可以像在第十一章中看到的那样,将合成外键指定给代码生成器,即jOOQ 键。
请考虑 jOOQ 手册和github.com/jOOQ/jOOQ/issues/12037以了解隐式连接支持的局限性。离开隐式连接的上下文,jOOQ 的导航方法也适用于表达自连接。
自连接
当一个表与自身连接时,我们可以依赖自连接。通过具有与表本身相同名称的导航方法来编写自连接。例如,以下是一个自连接,它检索包含每个员工及其老板名称(EMPLOYEE.REPORTS_TO)的结果集:
ctx.select(concat(EMPLOYEE.FIRST_NAME, inline(" "),
EMPLOYEE.LAST_NAME).as("employee"),
concat(EMPLOYEE.employee().FIRST_NAME, inline(" "),
EMPLOYEE.employee().LAST_NAME).as("reports_to"))
.from(EMPLOYEE)
.fetch();
在捆绑的代码ImplicitAndSelfJoin中,您可以练习更多关于隐式和自连接的示例。
NATURAL JOIN
之前,我们通过列出两个表(连接操作的左表和右表)中名称相同的字段(JOIN … USING语法)来使用JOIN … USING语法,这些字段应该在ON子句的条件中呈现。或者,我们可以依赖NATURAL JOIN,它不需要任何JOIN条件。这导致了一种极简的语法,但也使得我们的查询具有双刃剑的特点。
基本上,NATURAL JOIN自动识别两个连接表中共享名称的所有列,并使用它们来定义JOIN条件。当主键/外键列具有相同的名称时,这非常有用,如下例所示:
ctx.select().from(EMPLOYEE)
.naturalJoin(SALE)
.fetch();
jOOQ API 中的NATURAL JOIN依赖于naturalJoin()方法。除了这个方法之外,我们还有对应于LEFT/RIGHT/FULL NATURAL OUTER JOIN的naturalLeftOuterJoin()、naturalRightOuterJoin()和naturalFullOuterJoin()方法。此外,您可能还想阅读关于使用NATURAL FULL JOIN比较两个表的blog.jooq.org/2020/08/05/use-natural-full-join-to-compare-two-tables-in-sql/文章。您可以在捆绑的代码中看到所有这些方法的应用。
对于我们的示例,PostgreSQL 方言的渲染 SQL 如下:
SELECT "public"."employee"."employee_number", ...
"public"."sale"."sale_id", ...
FROM "public"."employee"
NATURAL JOIN "public"."sale"
EMPLOYEE和SALE表共享一个单列名称,EMPLOYEE_NUMBER——在EMPLOYEE中是主键,在SALE中是外键。这个列在幕后被NATURAL JOIN用于过滤结果,这是预期的行为。
但是,记住NATURAL JOIN会选取所有具有相同名称的列,而不仅仅是主键/外键列,因此这种JOIN可能会产生不希望的结果。例如,如果我们连接PAYMENT和BANK_TRANSACTION表,那么NATURAL JOIN将使用共同的复合键(CUSTOMER_NUMBER,CHECK_NUMBER),但也会使用CACHING_DATE列。如果这不是我们的意图,那么NATURAL JOIN就不是合适的选择。期望只使用(CUSTOMER_NUMBER,CHECK_NUMBER)是错误的假设,建议依赖于ON子句或 jOOQ 的onKey()方法:
ctx.select()
.from(PAYMENT.innerJoin(BANK_TRANSACTION).onKey())
.fetch();
另一方面,如果我们期望只使用CACHING_DATE列(这很难相信),那么USING子句可以是一个好的替代方案:
ctx.select()
.from(PAYMENT.innerJoin(BANK_TRANSACTION)
.using(PAYMENT.CACHING_DATE))
.fetch();
如果我们需要任何具有相同名称的列的任何自定义组合,USING子句是有用的。另一方面,NATURAL JOIN更容易出现问题,因为任何导致新匹配列名称的架构更改都会使NATURAL JOIN将新列也组合进去。
还值得记住的是,Oracle 不接受用于NATURAL JOIN过滤结果的列具有限定符(ORA-25155 – NATURAL join 中使用的列不能有限定符)。在这种情况下,使用默认设置的 jOOQ Java 模式会带来一些问题。例如,表达式ctx.select().from(EMPLOYEE).naturalJoin(SALE)…会导致 ORA-25155,因为默认情况下,jOOQ 会对SELECT中渲染的列进行限定,包括用于NATURAL JOIN的公共EMPLOYEE_NUMBER列。一个快速的解决方案是显式地通过asterisk()渲染*而不是列列表:
ctx.select(asterisk())
.from(PRODUCT)
.naturalJoin(TOP3PRODUCT)
.fetch();
或者,我们可以避免使用基于 Java 的模式,并编写如下:
ctx.select()
.from(table("EMPLOYEE"))
.naturalJoin(table("SALE"))
.fetch()
如果连接是INNER/LEFT OUTER JOIN,则未指定对公共列的引用被认为属于左侧表;如果是RIGHT OUTER JOIN,则属于右侧表。
或者,Oracle 的NATURAL JOIN与 Oracle 专有的具有连接条件的等值连接(等值连接依赖于包含等性运算符的连接条件)相同。
如同往常,你可以在捆绑的代码中练习所有这些示例以及更多。应用程序名为NaturalJoin。接下来,让我们来处理STRAIGHT JOIN。
STRAIGHT JOIN
从一开始,我们必须提到STRAIGHT JOIN是 MySQL 特有的。
基本上,STRAIGHT JOIN指示 MySQL 始终在JOIN的右侧表之前读取左侧表。在这种情况下,STRAIGHT JOIN可能有助于影响 MySQL 为某个JOIN选择的执行计划。每当我们认为查询优化器将JOIN表放置了错误的顺序时,我们都可以通过STRAIGHT JOIN来影响这个顺序。
例如,假设PRODUCT表有 5,000 行,ORDERDETAIL表有 20,000,000 行,ORDER表有 3,000 行,并且我们有一个如下所示的连接:
ctx.select(PRODUCT.PRODUCT_ID, ORDER.ORDER_ID)
.from(PRODUCT)
.innerJoin(ORDERDETAIL).on(
ORDERDETAIL.PRODUCT_ID.eq(PRODUCT.PRODUCT_ID))
.innerJoin(ORDER).on(
ORDER.ORDER_ID.eq(ORDERDETAIL.ORDER_ID))
.fetch();
现在,MySQL 可能会或不会考虑 ORDER.ORDER_ID 和 ORDERDETAIL.ORDER_ID 与 PRODUCT.PRODUCT_ID 和 ORDERDETAIL.PRODUCT_ID 之间的交集大小。如果 ORDERDETAIL 和 ORDER 之间的连接返回与 ORDERDETAIL 相同数量的行,那么这并不是一个最佳选择。如果从 PRODUCT 开始连接可以将 ORDERDETAIL 过滤到与 PRODUCT 相同数量的行,那么这将是一个最佳选择。这种行为可以通过 jOOQ 的 straightJoin() 方法强制执行,该方法生成一个 STRAIGHT JOIN 语句,如下所示:
ctx.select(PRODUCT.PRODUCT_ID, ORDER.ORDER_ID)
.from(PRODUCT)
.straightJoin(ORDERDETAIL).on(
ORDERDETAIL.PRODUCT_ID.eq(PRODUCT.PRODUCT_ID))
.innerJoin(ORDER).on(
ORDER.ORDER_ID.eq(ORDERDETAIL.ORDER_ID))
.fetch();
在 Oracle 中,可以通过 /*+LEADING(a, b)*/ 提示来更改 JOINs 的顺序。在 jOOQ 中,这种提示可以通过 hint() 方法传递:
ctx.select(PRODUCT.PRODUCT_ID, ORDER.ORDER_ID)
.hint("/*+LEADING(CLASSICMODELS.ORDERDETAIL
CLASSICMODELS.PRODUCT)*/")
… // joins come here
在 SQL Server 中,这可以通过 OPTION (FORCE ORDER) 实现:
ctx.select(PRODUCT.PRODUCT_ID, ORDER.ORDER_ID)
… // joins come here
.option("OPTION (FORCE ORDER)")
.fetch();
尽管如此,正如 Lukas Eder 在这里分享的:“MySQL 的问题应该由于他们添加了哈希连接支持而大大减轻。无论如何,我认为应该添加一个关于使用提示进行过早优化的免责声明。在有合理优化器的系统中,提示几乎不再需要了。”
你可以通过运行可用的 StraightJoin 应用程序来查看生成的 SQL。接下来,让我们介绍半连接和反连接。
半连接和反连接
半连接和反连接是关系代数运算符,在 SQL 语法中没有直接对应项。除了使用 Cloudera Impala 的情况,它为半/反连接提供了原生语法,我们不得不依赖解决方案。在这种情况下,半连接可以通过 EXISTS/IN 和反连接通过 NOT EXISTS/NOT IN 谓词来模拟。
由于半/反连接可以通过 (NOT) EXISTS/(NOT) IN 谓词来模拟,这意味着我们实际上并没有连接右侧。在半连接的情况下,我们只从第一个表(左侧表)中获取在第二个表(右侧表)中找到匹配的行,而在反连接的情况下,我们正好与半连接相反;我们只从第一个表(左侧表)中获取在第二个表(右侧表)中没有找到匹配的行。
例如,让我们获取所有具有 CUSTOMER 的 EMPLOYEE 的名称。通过通过 EXISTS 谓词模拟的半连接来完成此操作,在 SQL 中可以这样进行:
SELECT employee.first_name, employee.last_name FROM employee
WHERE EXISTS
(SELECT 1 FROM customer
WHERE employee.employee_number
= customer.sales_rep_employee_number);
在捆绑的代码中,你可以看到如何通过 jOOQ DSL API 表达这种 SQL。此外,你可以通过 IN 谓词模拟这种用例进行练习。现在,让我们使用 jOOQ 方法,它填补了表达能力的空白,并通过 leftSemiJoin() 方法强制执行使用半连接的明确意图。这个 jOOQ 方法为我们节省了很多麻烦——整洁的代码总是能够在不同的 SQL 方言中正确模拟,而且处理复杂情况(如嵌套 EXISTS/IN 谓词)时不会让人头疼,这会让你爱上这个方法:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.leftSemiJoin(CUSTOMER)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(
CUSTOMER.SALES_REP_EMPLOYEE_NUMBER))
.fetch();
这真是太棒了!查看捆绑的代码,SemiAndAntiJoin,以查看更多关于通过 jOOQ DSL API 链接和/或嵌套半连接的示例。每次,查看生成的 SQL 并对 jOOQ 表示衷心的感谢!
接下来,让我们专注于反连接。反连接是半连接的相反,通过 NOT EXISTS/NOT IN 谓词来模拟。例如,让我们编写一个表示反连接的 SQL,通过 NOT EXISTS 检索所有没有 CUSTOMER 的 EMPLOYEE 的名称:
SELECT employee.first_name, employee.last_name FROM employee
WHERE NOT (EXISTS
(SELECT 1
FROM customer
WHERE employee.employee_number
= customer.sales_rep_employee_number))
在捆绑的代码中,你可以看到如何通过 jOOQ DSL API 表达这个 SQL,以及基于 NOT IN 谓词的相同示例。然而,我强烈建议你避免使用 NOT IN 并选择 NOT EXISTS。
重要提示
很可能,你已经知道了这一点,但只是为了快速提醒,让我们提一下,EXISTS 和 IN 谓词是等价的,但 NOT EXISTS 和 NOT IN 谓词则不是,因为(如果有)NULL 值会导致不希望的结果。有关更多详细信息,请阅读这篇简短但至关重要的文章:blog.jooq.org/2012/01/27/sql-incompatibilities-not-in-and-null-values/。
或者,甚至更好,使用由 leftAntiJoin() 方法表示的 jOOQ 反连接:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE)
.leftAntiJoin(CUSTOMER)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(
CUSTOMER.SALES_REP_EMPLOYEE_NUMBER))
.fetch();
查看名为 SemiAndAntiJoin 的应用程序中的生成的 SQL 和更多示例。
反连接通常解决的问题是指关系除法或简单地除法。这是关系代数中的一个运算符,在 SQL 语法中没有直接对应物。简而言之,除法是 CROSS JOIN 操作的逆。
例如,让我们考虑 ORDERDETAIL 和 TOP3PRODUCT 表。虽然 CROSS JOIN 给我们的是笛卡尔积 ORDERDETAIL x TOP3PRODUCT,但除法给出的是 ORDERDETAIL ÷ TOP3PRODUCT 或 TOP3PRODUCT ÷ ORDERDETAIL。假设我们想要包含在 TOP3PRODUCT 中的至少三个产品的所有订单的 ID。这类任务是一种除法,通常通过两个嵌套的反连接来解决。解决此问题的 jOOQ 代码如下:
ctx.select()
.from(ctx.selectDistinct(ORDERDETAIL.ORDER_ID.as("OID"))
.from(ORDERDETAIL).asTable("T1")
.leftAntiJoin(TOP3PRODUCT
.leftAntiJoin(ORDERDETAIL)
.on(field("T", "OID")).eq(ORDERDETAIL.ORDER_ID)
.and(TOP3PRODUCT.PRODUCT_ID
.eq(ORDERDETAIL.PRODUCT_ID))))
.on(trueCondition()))
.fetch();
这很酷,比通过 NOT EXISTS 写同样的事情要简洁得多。但这还不是全部!jOOQ 提供了一个更优雅的解决方案,可以用来表达除法。这个解决方案使用 divideBy() 和 returning() 方法以简洁、表达性强且非常直观的方式表达除法。查看以下可以替换先前代码的代码:
ctx.select().from(ORDERDETAIL
.divideBy(TOP3PRODUCT)
.on(field(TOP3PRODUCT.PRODUCT_ID).eq(
ORDERDETAIL.PRODUCT_ID))
.returning(ORDERDETAIL.ORDER_ID))
.fetch();
查看这个示例以及另一个关于在 BootAntiJoinDivision 应用程序中查找包含至少给定订单产品订单的示例。
正如卢卡斯·埃德尔在这里指出的:“如果你想看到 x 是 ÷ 的逆,你可以选择两个不同的表,例如 A x B = C 和 C ÷ B = A”。
接下来,让我们来探讨 LATERAL/APPLY 连接。
LATERAL/APPLY 连接
本章最后讨论的是LATERAL/APPLY连接。这是标准 SQL 的一部分,与关联子查询非常相似,允许我们返回多行和多/或列,或者与 Java 的Stream.flatMap()类似。主要的是,横向内部子查询位于JOIN(INNER、OUTER等)的右侧,它可以被物化为经典子查询、派生表、函数调用、数组展开等。它的力量在于它可以引用(或横向访问)左侧的表/列来确定要保留哪些行。LATERAL连接会遍历左侧的每一行,为每一行评估内部子查询(右侧),就像典型的 for-each 循环一样。内部子查询返回的行保留到与外部查询的连接结果中。LATERAL关键字是必不可少的,因为没有它,每个子查询都会单独(独立)评估,无法访问左侧的列(FROM子句中的列)。
例如,选择所有具有DEPARTMENT的OFFICE可以通过LATERAL连接来完成:
ctx.select()
.from(OFFICE, lateral(select().from(DEPARTMENT)
.where(OFFICE.OFFICE_CODE.eq(
DEPARTMENT.OFFICE_CODE))).as("t"))
.fetch()
如您所见,jOOQ DSL API 提供了lateral()方法来塑造LATERAL连接。为 MySQL 方言渲染的 SQL 如下所示:
SELECT `classicmodels`.`office`.`office_code`,...
`t`.`department_id`,
...
FROM `classicmodels`.`office`,
LATERAL
(SELECT `classicmodels`.`department`.`department_id`,...
FROM `classicmodels`.`department`
WHERE `classicmodels`.`office`.`office_code`
= `classicmodels`.`department`.`office_code`) AS `t`
没有显式的JOIN,你会期望CROSS JOIN(INNER JOIN ON true / INNER JOIN IN 1=1)会自动推断。通过LEFT OUTER JOIN LATERAL编写前面的查询需要一个虚拟的ON true / ON 1=1子句,如下所示:
ctx.select()
.from(OFFICE)
.leftOuterJoin(lateral(select().from(DEPARTMENT)
.where(OFFICE.OFFICE_CODE
.eq(DEPARTMENT.OFFICE_CODE))).as("t"))
.on(trueCondition())
.fetch();
LATERAL连接有几个用例,它非常适合。例如,它可以用于数组的横向展开,用于查找每个Foo的 TOP-N(将 TOP-N 查询与普通表连接),并且与所谓的表值函数结合得很好。
展开数组列
如果你是一个 Oracle 或 PostgreSQL 的粉丝,那么你应该知道它们对嵌套数组(或嵌套集合)的支持。在 PostgreSQL 中,我们可以声明一个类型为数组的列,就像声明任何其他类型一样,但以方括号[](例如,text[])结尾。由于 Oracle 只识别名义数组类型,我们必须首先通过CREATE TYPE来创建它们。我不会坚持这个纯 SQL 方面,因为我们的目标是跳入 jOOQ DSL API 的使用。
因此,让我们考虑具有名为TOPIC的数组列的DEPARTMENT表。对于每个部门,我们有一个主题列表(活动领域),并且可能有更多部门有交错的主题。例如,对于Sale部门,我们有四个主题——'commerce'、'trade'、'sellout'和'transaction'。
现在,假设我们想要获取具有共同主题'commerce'和'business'的部门。为此,我们可以通过 jOOQ DSL API 使用lateral()方法编写一个LATERAL连接,并通过unnest()方法展开数组(将数组转换为可用的/可查询的表),如下所示:
ctx.select()
.from(DEPARTMENT, lateral(select(field(name("t", "topic")))
.from(unnest(DEPARTMENT.TOPIC).as("t", "topic"))
.where(field(name("t", "topic"))
.in("commerce", "business"))).as("r"))
.fetch();
对于 PostgreSQL 方言,渲染 SQL 如下所示:
SELECT
"public"."department"."department_id",
...
"public"."department"."accrued_liabilities",
"r"."topic"
FROM
"public"."department",
LATERAL (SELECT
"t"."topic"
FROM
unnest("public"."department"."topic")
AS "t" ("topic")
WHERE
"t"."topic" IN (?, ?)) AS "r"
注意,MySQL 和 SQL Server 不支持数组(集合)列,但我们可以仍然声明匿名类型的数组,这些数组可以通过相同的 jOOQ unnest() 方法展开。接下来,让我们谈谈解决每个 Foo 的 TOP-N 任务。
解决每个 Foo 的 TOP-N 问题
虽然在整个数据集上解决 TOP-N 问题可能相当具有挑战性,但解决每个 Foo 的 TOP-N 问题可能真的很难理解。幸运的是,LATERAL 连接非常适合这类问题。例如,获取每个员工的 TOP-3 销售额可以用 jOOQ 表达如下:
ctx.select(EMPLOYEE.EMPLOYEE_NUMBER, EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, field(name("t", "sales")))
.from(EMPLOYEE,
lateral(select(SALE.SALE_.as("sales"))
.from(SALE)
.where(EMPLOYEE.EMPLOYEE_NUMBER
.eq(SALE.EMPLOYEE_NUMBER))
.orderBy(SALE.SALE_.desc())
.limit(3).asTable("t")))
.orderBy(EMPLOYEE.EMPLOYEE_NUMBER)
.fetch();
LATERAL 连接允许我们访问 EMPLOYEE.EMPLOYEE_NUMBER 字段/列,这就是所有魔法所在!MySQL 方言的渲染 SQL 如下所示:
SELECT
`classicmodels`.`employee`.`employee_number`,
`classicmodels`.`employee`.`first_name`,
`classicmodels`.`employee`.`last_name`,
`t`.`sales`
FROM `classicmodels`.`employee`,
LATERAL (SELECT `classicmodels`.`sale`.`sale` as `sales`
FROM `classicmodels`.`sale`
WHERE `classicmodels`.`employee`.`employee_number`
= `classicmodels`.`sale`.`employee_number`
ORDER BY `classicmodels`.`sale`.`sale` desc limit ?)
as `t`
ORDER BY `classicmodels`.`employee`.`employee_number`
如果我们将通过内部 SELECT 获得的派生表视为一个具有员工编号作为参数的表值函数,那么在 Oracle 中,我们可以这样写:
CREATE TYPE "TABLE_RES_OBJ" AS OBJECT (SALES FLOAT);
CREATE TYPE "TABLE_RES" AS TABLE OF TABLE_RES_OBJ;
CREATE OR REPLACE NONEDITIONABLE FUNCTION
"TOP_THREE_SALES_PER_EMPLOYEE" ("employee_nr" IN NUMBER)
RETURN TABLE_RES IS
"table_result" TABLE_RES;
BEGIN
SELECT
TABLE_RES_OBJ("SALE"."SALE") "sales"
BULK COLLECT
INTO "table_result"
FROM
"SALE"
WHERE
"employee_nr" = "SALE"."EMPLOYEE_NUMBER"
ORDER BY
"SALE"."SALE" DESC
FETCH NEXT 3 ROWS ONLY;
RETURN "table_result";
END;
接下来,我们可以使用 LATERAL 连接来调用此函数。jOOQ 代码如下:
ctx.select(EMPLOYEE.EMPLOYEE_NUMBER, EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, field(name("T", "SALES")))
.from(EMPLOYEE, lateral(select().from(
TOP_THREE_SALES_PER_EMPLOYEE
.call(EMPLOYEE.EMPLOYEE_NUMBER)).asTable("T")))
.orderBy(EMPLOYEE.EMPLOYEE_NUMBER)
.fetch();
Oracle 的渲染 SQL 如下所示:
SELECT
"CLASSICMODELS"."EMPLOYEE"."EMPLOYEE_NUMBER",
"CLASSICMODELS"."EMPLOYEE"."FIRST_NAME",
"CLASSICMODELS"."EMPLOYEE"."LAST_NAME",
"T"."SALES"
FROM "CLASSICMODELS"."EMPLOYEE",
LATERAL (SELECT
"TOP_THREE_SALES_PER_EMPLOYEE"."SALES"
FROM
table("CLASSICMODELS"."TOP_THREE_SALES_PER_EMPLOYEE"
("CLASSICMODELS"."EMPLOYEE"."EMPLOYEE_NUMBER"))
"TOP_THREE_SALES_PER_EMPLOYEE") "T"
ORDER BY "CLASSICMODELS"."EMPLOYEE"."EMPLOYEE_NUMBER"
TOP_THREE_SALES_PER_EMPLOYEE 静态字段是由 jOOQ 生成器生成的,基本上是一个普通的表,位于 jooq.generated.tables 包下的 TopThreeSalesPerEmployee 名称下。它可以在 SELECT 的 FROM 子句中像任何其他表一样使用。然而,请注意,我们有一个名为 call() 的方法,用于调用(带有参数)此表值函数。然而,尽管大多数数据库将表值函数视为普通表,但在 Oracle 中,将它们视为独立例程的情况相当普遍。在这种情况下,jOOQ 有一个标志设置,允许我们指示是否将表值函数视为普通表(true)或作为普通例程(false)。根据此设置,jOOQ 将生成的代码放置在表部分或例程部分。此设置在所有支持的数据库中除 Oracle 外都设置为 true。要启用此设置,我们必须设置以下内容:
Maven: <tableValuedFunctions>true</tableValuedFunctions>
Gradle: database { tableValuedFunctions = true }
或者,以编程方式:
...withDatabase(new Database()
.withTableValuedFunctions(true)
虽然 LATERAL 关键字(顺便说一下,这是一个相当令人困惑的词)可以在 MySQL、PostgreSQL 和 Oracle 中使用,但不能在 SQL Server 中使用。实际上,SQL Server 和 Oracle 通过 APPLY 关键字支持 CROSS APPLY 和 OUTER APPLY。
CROSS APPLY 和 OUTER APPLY
专门针对 T-SQL,CROSS APPLY 和 OUTER APPLY 使用更具说明性的 APPLY 关键字,这表明我们将函数应用于每一行表。主要来说,CROSS APPLY 与 CROSS JOIN LATERAL 相同,而 OUTER APPLY 与 LEFT OUTER JOIN LATERAL 相同。这正是 jOOQ 在不支持(例如,在 PostgreSQL 中)CROSS/OUTER APPLY 时如何模拟 CROSS/OUTER APPLY 的方式。
我猜每个人都同意卢卡斯·埃德(Lukas Eder)的说法:“我发现 APPLY 更加直观,尤其是在跨应用表值函数时。T CROSS APPLY F (T.X)意味着我们将 F 应用于 T 中的每一行,并在 T 和 F 的结果之间创建交叉积。另一方面,LATERAL 在语法上非常奇怪,尤其是总是需要写 ON TRUE 的这个愚蠢要求。”
在本节的开头,我们编写了一个LATERAL连接来选择所有有DEPARTMENT的OFFICE。通过使用CROSS APPLY并通过 jOOQ 的crossApply()方法来完成相同的事情,如下所示:
ctx.select()
.from(OFFICE).crossApply(select()
.from(DEPARTMENT)
.where(OFFICE.OFFICE_CODE
.eq(DEPARTMENT.OFFICE_CODE)).asTable("t"))
.fetch();
SQL Server 的渲染 SQL 如下:
SELECT [classicmodels].[dbo].[office].[office_code], ...
[t].[department_id], ...
FROM [classicmodels].[dbo].[office] CROSS APPLY
(SELECT [classicmodels].[dbo].[department].[department_id],
...
FROM [classicmodels].[dbo].[department]
WHERE [classicmodels].[dbo].[office].[office_code]
= [classicmodels].[dbo].[department].[office_code] ) [t]
通过LEFT OUTER JOIN LATERAL编写前面的查询需要使用一个虚拟的ON true/1=1子句,但通过 jOOQ 的outerApply()方法使用OUTER APPLY可以消除这个小不便之处:
ctx.select()
.from(OFFICE)
.outerApply(select()
.from(DEPARTMENT)
.where(OFFICE.OFFICE_CODE
.eq(DEPARTMENT.OFFICE_CODE)).asTable("t"))
.fetch();
SQL Server 方言渲染的 SQL 如下:
SELECT [classicmodels].[dbo].[office].[office_code], ...
[t].[department_id], ...
FROM [classicmodels].[dbo].[office] OUTER APPLY
(SELECT [classicmodels].[dbo].[department].[department_id],
...
FROM [classicmodels].[dbo].[department]
WHERE [classicmodels].[dbo].[office].[office_code]
= [classicmodels].[dbo].[department].[office_code] ) [t]
完成!在捆绑的代码中,你可以练习cross/outerApply()和表值函数的示例。
重要提示
在本章的示例中,我们使用了fooJoin(TableLike<?> table)和cross/outerApply(TableLike<?> table),但 jOOQ API 还包含其他变体,例如fooJoin(String sql)、cross/outerApply(String sql)、fooJoin(SQL sql)、cross/outerApply(SQL sql)、fooJoin(String sql, Object... bindings)、cross/outerApply(String sql, Object... bindings)、fooJoin(String sql, QueryPart... parts)和cross/outerApply(String sql, QueryPart... parts)。所有这些都在 jOOQ 文档中,并标记为@PlainSQL。这个注解指出允许我们产生在 AST 内部渲染“纯 SQL”的QueryPart的方法/类型,这些内容在第十六章中介绍,即处理别名和 SQL 模板。
本章的所有示例(以及更多)都可以在LateralJoin应用程序中找到。请花时间练习每个示例。
摘要
在本章中,我们介绍了一系列 SQL JOIN操作及其如何通过 jOOQ DSL API 表达。我们从众所周知的INNER/OUTER/CROSS JOIN开始,接着是隐式连接、自连接、NATURAL和STRAIGHT JOIN,最后是半/反连接、CROSS APPLY、OUTER APPLY和LATERAL连接。此外,我们还涵盖了USING子句和令人惊叹的 jOOQ onKey()方法。
在下一章中,我们将探讨 jOOQ 的类型、转换器和绑定。
第七章: 类型、转换器和绑定
数据类型、转换器和绑定代表了通过基于 Java 的 领域特定语言 (DSL) 应用程序编程接口 (API) 与数据库交互的主要方面。迟早,标准的 结构化查询语言 (SQL)/Java 数据库连接 (JDBC) 数据类型将不足以满足需求,或者 Java 类型和 JDBC 类型之间的默认映射将在你的特定场景中引起一些不足。在那个时刻,你将感兴趣于创建新的数据类型,使用自定义数据类型,以及你的 DSL API 的类型转换和类型绑定能力。幸运的是,jOOQ 面向对象的查询 (jOOQ) DSL 提供了多功能且易于使用的 API,专注于以下议程,这也是本章的主题:
-
默认数据类型转换
-
自定义数据类型和类型转换
-
自定义数据类型和类型绑定
-
操作枚举
-
数据类型重写
-
处理可嵌入类型
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter07。
默认数据类型转换
jOOQ 允许我们以平滑方式使用的一个方面是其 默认数据类型转换。大多数时候,jOOQ 会隐藏 JDBC 和 Java 类型之间转换的仪式。例如,你是否好奇以下显式转换是如何工作的?看看下面:
Record1<Integer> fiscalYear = ctx.select(field("fiscal_year",
Integer.class)).from(table("sale")).fetchAny();
// Offtake is a POJO
Offtake offtake = ctx.select(field("fiscal_year"),
field("sale"), field("employee_number")).from(table("sale"))
.fetchAnyInto(Offtake.class);
这两种转换都通过 默认数据类型转换 或 自动转换 来解决。在幕后,jOOQ 依赖于它自己的 API,能够为 Object 类型、数组以及集合执行软类型安全的转换。
你可以在 ConvertUtil 应用程序中查看这个示例。
自定义数据类型和类型转换
在 jOOQ 中,所有特定方言数据类型的通用接口命名为 org.jooq.DataType<T>,其中 T 代表与 SQL 数据类型关联的 Java 类型。每个 T Java 数据类型与由 java.sql.Types 表示的 SQL 数据类型的关联(称为通用 SQL 类型,即标准 JDBC 类型)都存在于 jOOQ 的 org.jooq.impl.SQLDataType API 中。jOOQ 代码生成器自动将 Java 类型映射到这个 SQLDataType API,对于大多数方言,它与数据库的数据类型几乎 1:1 匹配。当然,我们不包括一些供应商特定的数据类型,例如空间数据类型、PostgreSQL 的 INET/HSTORE,以及其他非标准 JDBC 类型(JDBC 未明确支持的数据类型)。
大概来说,任何与 jOOQ API 没有关联标准 JDBC 类型的非数据类型都被视为和当作自定义数据类型。然而,正如卢卡斯·埃德尔提到的:“我认为有些数据类型应该是标准 JDBC 类型,但不是。它们也列在 SQLDataType*中,包括:JSON、JSONB、UUID、BigInteger(!)、无符号数字、区间。这些不需要自定义数据类型。”
当你的自定义数据类型需要映射到标准 JDBC 类型——即org.jooq.impl.SQLDataType类型时,你需要提供并明确指定一个org.jooq.Converter实现。这个转换器执行涉及类型之间的转换的艰苦工作。
重要提示
当我们想要将一个类型映射到非标准 JDBC 类型(不在org.jooq.impl.SQLDataType中的类型)时,我们需要关注org.jooq.Binding API,这将在后面进行介绍。因此,如果这是你的情况,不要试图将你的转换逻辑强行应用到Converter上。只需使用Binding(我们将在本章后面看到这一点)。
请注意,尝试在不通过转换器的情况下插入自定义数据类型的价值/数据可能会导致数据库中插入null值(正如卢卡斯·埃德尔分享的那样:“这种 null 行为是一个旧的设计缺陷。很久以前,我没有遵循尽早失败策略抛出异常”),而尝试在没有转换器的情况下获取自定义数据类型的数据可能会导致org.jooq.exception.DataTypeException,没有找到 Foo 和 Buzz 类型的转换器。
编写 org.jooq.Converter 接口
org.jooq.Converter是一个接口,它表示两种类型之间的转换,这两种类型被泛型表示为<T>和<U>。通过<T>,我们表示数据库类型,通过<U>,我们表示<T>到<U>的转换是通过一个名为U from(T)的方法完成的,而从<U>到<T>的转换是通过一个名为T to(U)的方法完成的。
如果你发现很难记住哪个方向是"from()",哪个方向是"to()",那么可以这样想:前者可以读作"从数据库到客户端",后者可以读作"从客户端到数据库"。此外,请注意不要混淆T和U,因为这样你可能会花费数小时盯着生成的代码中的编译错误。
换句话说,通过U from(T),我们将数据库类型转换为 UDT(例如,这在SELECT语句中很有用),通过T to(U),我们将 UDT 转换为数据库类型(例如,这在INSERT、UPDATE和DELETE语句中很有用)。此外,T to(U)方向用于任何使用绑定变量的地方,所以也在SELECT时编写谓词——例如,T.CONVERTED.eq(u)。org.jooq.Converter的存根如下:
public interface Converter<T, U> {
U from(T databaseObject); // convert to user-defined type
T to(U userDefinedObject); // convert to database type
// Class instances for each type
Class<T> fromType();
Class<U> toType();
}
jOOQ 提供了这个接口的抽象实现(AbstractConverter)以及一些抽象的具体系列(转换器),您可以在以下链接中探索:www.jooq.org/javadoc/latest/org.jooq/org/jooq/impl/AbstractConverter.html。但正如您接下来会看到的,我们可以编写自己的转换器。
例如,如果您想在应用程序中使用 Java 8 的 java.time.YearMonth 类型,但在数据库中存储为 SQL INTEGER 类型,您可以编写如下转换器:
public class YearMonthConverter
implements Converter<Integer, YearMonth> {
@Override
public YearMonth from(Integer t) {
if (t != null) {
return YearMonth.of(1970, 1)
.with(ChronoField.PROLEPTIC_MONTH, t);
}
return null;
}
@Override
public Integer to(YearMonth u) {
if (u != null) {
return (int) u.getLong(ChronoField.PROLEPTIC_MONTH);
}
return null;
}
@Override
public Class<Integer> fromType() {
return Integer.class;
}
@Override
public Class<YearMonth> toType() {
return YearMonth.class;
}
}
通过 new YearMonthConverter() 或定义一个方便的 static 类型,如下所示来使用这个转换器:
public static final Converter<Integer, YearMonth>
INTEGER_YEARMONTH_CONVERTER = new YearMonthConverter();
此外,使用此转换器处理数组可以通过以下 static 类型完成,如下所示:
public static final Converter<Integer[], YearMonth[]>
INTEGER_YEARMONTH_ARR_CONVERTER
= INTEGER_YEARMONTH_CONVERTER.forArrays();
一旦我们有了转换器,我们就可以定义一个新的数据类型。更确切地说,我们通过调用 asConvertedDataType(Converter) 或 asConvertedDataType(Binding) 程序化地定义自己的 DataType 类型。例如,在这里,我们定义了一个 YEARMONTH 数据类型,它可以像 SQLDataType 中定义的任何其他数据类型一样使用:
public static final DataType<YearMonth> YEARMONTH
= INTEGER.asConvertedDataType(INTEGER_YEARMONTH_CONVERTER);
这里,INTEGER 是 org.jooq.impl.SQLDataType.INTEGER 数据类型。
在 CUSTOMER 表中,我们有一个名为 FIRST_BUY_DATE 的字段,其类型为 INT。当客户进行首次购买时,我们将日期(年月)存储为整数。例如,日期 2020-10 被存储为 24249(我们手动应用了 Integer to(YearMonth u) 方法)。如果没有转换器,我们必须显式地插入 24249;否则,代码将无法编译(例如,类型安全的 INSERT 语句将无法编译)或者我们可能会得到一个无效的插入(例如,非类型安全的 INSERT 语句可能会存储 null)。依赖我们的转换器,我们可以编写以下类型安全的 INSERT 语句:
ctx.insertInto(CUSTOMER, CUSTOMER.CUSTOMER_NAME, ... ,
CUSTOMER.FIRST_BUY_DATE)
.values("Atelier One", ...,
INTEGER_YEARMONTH_CONVERTER.to(YearMonth.of(2020, 10)))
.execute();
接下来,在不使用转换器的情况下获取 Atelier One 的所有 FIRST_BUY_DATE 值将导致得到一个整数数组或列表。要获取 YearMonth 类型的数组/列表,我们可以使用转换器,如下所示:
List<YearMonth> resultListYM
= ctx.select(CUSTOMER.FIRST_BUY_DATE).from(CUSTOMER)
.where(CUSTOMER.CUSTOMER_NAME.eq("Atelier One"))
.fetch(CUSTOMER.FIRST_BUY_DATE,
INTEGER_YEARMONTH_CONVERTER);
在捆绑的代码(YearMonthConverter,适用于 MySQL 和 PostgreSQL)中,您可以看到更多示例,包括使用 YEARMONTH 数据类型进行强制转换和类型转换的操作。
当转换器在不同位置/类中偶尔使用时,编写具有其自己类的转换器是有用的。如果您知道转换器只在一个类中使用,那么您可以通过 Converter.of()/ofNullable() 在该类中本地定义它,如下所示(它们之间的区别在于 Converter.ofNullable() 对于 null 输入始终返回 null):
Converter<Integer, YearMonth> converter =
Converter.ofNullable(Integer.class, YearMonth.class,
(Integer t) -> {
return YearMonth.of(1970, 1)
.with(ChronoField.PROLEPTIC_MONTH, t);
},
(YearMonth u) -> {
return (int) u.getLong(ChronoField.PROLEPTIC_MONTH);
}
);
此外,从 jOOQ 3.15+ 开始,我们可以使用所谓的 ad hoc 转换器。这种类型的转换器对于将转换器附加到特定列以供单个查询或几个局部查询使用非常有用。例如,有一个转换器(INTEGER_YEARMONTH_CONVERTER),我们可以用它来处理单个列,如下所示:
ctx.insertInto(CUSTOMER, CUSTOMER.CUSTOMER_NAME, ...,
CUSTOMER.FIRST_BUY_DATE.convert(INTEGER_YEARMONTH_CONVERTER))
.values("Atelier One", ..., YearMonth.of(2020, 10))
.execute();
为了方便起见,jOOQ 除了提供特定的convert()函数(允许你将Field<T>类型转换为Field<U>类型,反之亦然)之外,还提供了convertTo()(允许你将Field<U>类型转换为Field<T>类型)和convertFrom()(允许你将Field<T>类型转换为Field<U>类型)的特定风味。由于我们的INSERT语句无法利用转换器的两个方向,我们可以回退到convertTo(),如下所示:
ctx.insertInto(CUSTOMER, CUSTOMER.CUSTOMER_NAME, ...,
CUSTOMER.FIRST_BUY_DATE.convertTo(YearMonth.class,
u -> INTEGER_YEARMONTH_CONVERTER.to(u)))
.values("Atelier One", ..., YearMonth.of(2020, 10))
.execute();
或者,在SELECT语句的情况下,你可能希望使用converterFrom(),如下所示:
List<YearMonth> result = ctx.select(
CUSTOMER.FIRST_BUY_DATE.convertFrom(
t -> INTEGER_YEARMONTH_CONVERTER.from(t)))
.from(CUSTOMER)
.where(CUSTOMER.CUSTOMER_NAME.eq("Atelier One"))
.fetchInto(YearMonth.class);
当然,你甚至不需要在单独的类中定义转换器的负载。你可以简单地内联它,就像我们在这里所做的那样:
ctx.insertInto(CUSTOMER, ...,
CUSTOMER.FIRST_BUY_DATE.convertTo(YearMonth.class,
u -> (int) u.getLong(ChronoField.PROLEPTIC_MONTH)))
.values(..., YearMonth.of(2020, 10)) ...;
List<YearMonth> result = ctx.select(
CUSTOMER.FIRST_BUY_DATE.convertFrom(
t -> YearMonth.of(1970, 1)
.with(ChronoField.PROLEPTIC_MONTH, t)))
.from(CUSTOMER) ...;
你可以查看YearMonthAdHocConverter中的示例,了解 MySQL 和 PostgreSQL 的特定转换器。
进一步来说,可以通过嵌套to()/from()方法的调用来嵌套转换器,可以通过<X> Converter<T,X> andThen(Converter<? super U, X> converter)方法进行链式调用。嵌套和链式调用都在捆绑代码(YearMonthConverter)中通过使用第二个转换器进行了示例,该转换器在YearMonth和Date之间进行转换,命名为YEARMONTH_DATE_CONVERTER。
此外,如果你想从<T, U>逆转换到<U, T>,那么就依靠Converter.inverse()方法。这在嵌套/链式调用转换器时可能很有用,可能需要你逆转换T与U以获得数据类型之间的适当匹配。这也在捆绑代码中得到了示例。
新的数据类型可以根据converter进行定义,如下所示:
DataType<YearMonth> YEARMONTH
= INTEGER.asConvertedDataType(converter);
也可以不使用显式的Converter来定义新的数据类型。只需使用public default <U> DataType<U> asConvertedDataType(Class<U> toType, Function<? super T,? extends U> from, Function<? super U,? extends T> to)这种风味,就像捆绑代码中那样,jOOQ 将在幕后使用Converter.of(Class, Class, Function, Function)。
另一方面,如果一个转换器被大量使用,那么最好允许 jOOQ 在没有显式调用的情况下自动应用它,就像之前的示例中那样。为了实现这一点,我们需要对 jOOQ 代码生成器进行适当的配置。
为转换器强制类型
通过使用所谓的强制类型 (<forcedTypes/>),我们可以指示 jOOQ 代码生成器覆盖列的数据类型。实现这一目标的一种方法是通过org.jooq.Converter将列数据类型映射到用户定义的数据类型。
此配置步骤依赖于使用 <forcedTypes/> 标签,它是 <database/> 标签的子标签。在 <forcedTypes/> 标签下,我们可以有一个或多个 <forcedType/> 标签,并且每个这样的标签都封装了覆盖列数据类型的特定情况。每种情况都通过几个标签定义。首先,我们有 <userType/> 和 <converter/> 标签,用于将 UDT 和适当的 Converter 链接起来。其次,我们有几个标签用于通过名称和/或类型识别某个特定列(或多个列)。虽然你可以在 jOOQ 手册中找到所有这些标签的描述(www.jooq.org/doc/latest/manual/code-generation/codegen-advanced/codegen-config-database/codegen-database-forced-types/),但在这里我们提到两个最常用的标签:<includeExpression/> 和 <includeTypes/>。<includeExpression/> 包含一个 Java <includeTypes/> 包含一个 Java 正则表达式,匹配应该强制具有此类型的数据类型(<userType/> 类型)。在存在多个正则表达式的情况下,使用管道操作符(|)分隔它们,并且如果 <includeExpression/> 和 <includeTypes/> 在同一个 <forcedType/> 标签中存在,那么请记住它们必须匹配。
例如,YearMonthConverter 的 <forcedType/> 类型看起来像这样:
<forcedTypes>
<forcedType>
<!-- The Java type of the custom data type.
This corresponds to the Converter's <U> type. -->
<userType>java.time.YearMonth</userType>
<!-- Associate that custom type with our converter. -->
<converter>
com.classicmodels.converter.YearMonthConverter
</converter>
<!-- Match the fully-qualified column. -->
<includeExpression>
classicmodels\.customer\.first_buy_date
</includeExpression>
<!-- Match the data type to be forced. -->
<includeTypes>INT</includeTypes>
</forcedType>
</forcedTypes>
注意我们是如何通过包含模式、表和列名称的表达式来识别 first_buy_date 列的。在其他情况下,你可能希望使用更宽松的表达式;因此,这里有一些流行的例子:
<!-- All 'first_buy_date' fields in any 'customer' table,
no matter the schema -->
.*\.customer\.first_buy_date
<!-- All 'first_buy_date' fields,
no matter the schema and the table -->
.*\.first_buy_date
<!-- All fields containing 'first_buy_' -->
.*\.first_buy_.*
<!-- Case-insensitive expressions -->
(?i:.*\.customer\.first_buy_date)
(?i:classicmodels\.customer\.first_buy_date)
重要提示
注意,jOOQ 代码生成器中的所有正则表达式都匹配以下内容之一:
-
完全限定对象名称(FQONs)
-
部分限定对象名称
-
未限定对象名称
因此,除了 .*\.customer\.first_buy_date,你也可以直接写 customer\.first_buy_date。
此外,请记住,默认情况下,正则表达式是区分大小写的。当你使用多个方言时,这一点很重要(例如,Oracle 标识符(IDs)是 UPPER_CASE,在 PostgreSQL 中,它们是 lower_case,而在 SQL Server 中,它们是 PascalCase)。
此外,匹配任何类型是通过 <includeTypes>.*</includeTypes> 完成的,而匹配特定类型如 NVARCHAR(4000) 是通过 NVARCHAR\(4000\) 完成的,而类型如 NUMBER(1, 0) 是通过 NUMBER\(1,\s*0\) 完成的。有关此示例的更详细注释的完整版本可在捆绑的代码中找到。
这次,FIRST_BUY_DATE 字段没有映射到 java.lang.Integer。如果我们检查生成的与 CUSTOMER 表对应的表类(jooq.generated.tables.Customer),那么我们会看到以下声明:
public final TableField<CustomerRecord, YearMonth>
FIRST_BUY_DATE = createField(DSL.name("first_buy_date"),
SQLDataType.INTEGER, this, "", new YearMonthConverter());
因此,FIRST_BUY_DATE 映射到 YearMonth,因此我们之前的 INSERT 和 SELECT 语句现在看起来是这样的:
ctx.insertInto(CUSTOMER, CUSTOMER.CUSTOMER_NAME, ...,
CUSTOMER.FIRST_BUY_DATE)
.values("Atelier One", ..., YearMonth.of(2020, 10))
.execute();
然后,SELECT 语句将看起来像这样:
List<YearMonth> ymList = ctx.select(CUSTOMER.FIRST_BUY_DATE)
.from(CUSTOMER)
.where(CUSTOMER.CUSTOMER_NAME.eq("Atelier One"))
.fetch(CUSTOMER.FIRST_BUY_DATE);
jOOQ 会自动应用我们的转换器,因此不需要显式调用它。它甚至在我们执行 ResultQuery<R1> 到 ResultQuery<R2> 的强制操作时也能工作,如下所示:
Result<Record2<String, YearMonth>> result = ctx.resultQuery(
"SELECT customer_name, first_buy_date FROM customer")
.coerce(CUSTOMER.CUSTOMER_NAME, CUSTOMER.FIRST_BUY_DATE)
.fetch();
换句话说,jOOQ 会自动使用我们的转换器来绑定变量和从 java.util.ResultSet 中获取数据。在查询中,我们只需将 FIRST_BUY_DATE 视为 YEARMONTH 类型。代码命名为 YearMonthConverterForcedTypes 并适用于 MySQL。
通过 Converter.of() 或 Converter.ofNullable() 定义内联转换器
在前面的章节中,我们的转换器被编写为一个 Java 类,并在 jOOQ 代码生成器的配置中引用了这个类。但我们可以将自定义数据类型与一个 内联转换器 关联起来,这是一个直接写入配置的转换器。为此,我们使用 <converter/> 标签,如下所示:
<forcedTypes>
<forcedType>
<userType>java.time.YearMonth</userType>
...
<converter>
<![CDATA[
org.jooq.Converter.ofNullable(
Integer.class, YearMonth.class,
(Integer t) -> { return YearMonth.of(1970, 1).with(
java.time.temporal.ChronoField.PROLEPTIC_MONTH, t); },
(YearMonth u) -> { return (int) u.getLong(
java.time.temporal.ChronoField.PROLEPTIC_MONTH); }
)
]]>
</converter>
...
</forcedType>
</forcedTypes>
这个转换器的使用部分保持不变。完整的代码命名为 InlineYearMonthConverter,程序化版本命名为 ProgrammaticInlineYearMonthConverter。这两种应用都适用于 MySQL。
通过 lambda 表达式定义内联转换器
可以通过 <lambdaExpression/> 编写更简洁的内联转换器。这个标签使我们免去了显式使用 Converter.of()/Converter.ofNullable() 的麻烦,并允许我们简单地指定一个通过 <from/> 标签从数据库类型转换的 lambda 表达式,以及一个通过 <to/> 标签转换到数据库类型的 lambda 表达式。让我们在我们的转换器中举例说明,如下所示:
<forcedTypes>
<forcedType>
<userType>java.time.YearMonth</userType>
...
<lambdaConverter>
<from>
<![CDATA[(Integer t) -> { return YearMonth.of(1970, 1)
.with(java.time.temporal.ChronoField.PROLEPTIC_MONTH, t);
}]]>
</from>
<to>
<![CDATA[(YearMonth u) -> { return (int)
u.getLong(java.time.temporal.ChronoField.PROLEPTIC_MONTH);
}]]>
</to>
</lambdaConverter>
...
</forcedType>
</forcedTypes>
再次强调,这个转换器的使用部分保持不变。完整的代码命名为 LambdaYearMonthConverter,程序化版本命名为 ProgrammaticLambdaYearMonthConverter。这两种应用都适用于 MySQL。
通过 SQL 匹配强制类型
在前面的章节中,我们通过 <includeExpression/> 和 <includeTypes/> 标签使用正则表达式匹配列名。每当我们需要更复杂的匹配列名的标准时,我们都可以依赖 <sql/> 标签。这个标签的正文是一个针对我们数据库字典视图执行的 SQL 查询。例如,匹配来自我们的 MySQL classicmodels 数据库的所有 TIMESTAMP 类型的列可以这样实现:
<sql>
SELECT concat('classicmodels.', TABLE_NAME, '.', COLUMN_NAME)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'classicmodels'
AND TABLE_NAME != 'flyway_schema_history'
AND DATA_TYPE = 'timestamp'
</sql>
这应该返回几个列,其中包含来自 PAYMENT 表的两个列和来自 BANK_TRANSACTION 表的一个列:PAYMENT.PAYMENT_DATE、PAYMENT.CACHING_DATE 和 BANK_TRANSACTION.CACHING_DATE。对于这些列,jOOQ 将应用在捆绑代码中开发的 Converter<LocalDateTime, JsonNode>。但我们的查询返回的不仅仅是这些列,jOOQ 还会将此转换器应用于 PAYMENT.MODIFIED 和 TOKEN.UPDATED_ON,它们也是 TIMESTAMP 类型。现在,我们有两种避免这种情况的方法——我们可以相应地调整我们的查询谓词,或者我们可以快速添加 <excludeExpression/>,如下所示:
<excludeExpression>
classicmodels\.payment\.modified
| classicmodels\.token\.updated_on
</excludeExpression>
您可以在名为 SqlMatchForcedTypes 的名称下找到 MySQL 的示例。
我很确信你已经明白了这个概念,并且知道如何为你的数据库编写这样的查询。
JSON 转换器
当 jOOQ 检测到数据库使用JSON类型时,它将数据库类型映射到org.jooq.JSON类。这是一个非常方便的类,它代表了一个从数据库中获取的整洁 JSON 包装类型。它的 API 包括JSON.data()方法,该方法返回org.jooq.JSON的字符串表示形式,以及JSON.valueOf(String data)方法,该方法从字符串表示形式返回org.jooq.JSON。通常,org.jooq.JSON就是你所需要的,但如果你想要通过专用 API(Jackson、Gson、JSON Binary(JSONB)等)来操作获取的 JSON,那么你需要一个转换器。
因此,为了练习更多示例,本书附带的自带代码包含一个 JSON 转换器(JsonConverter),如以下更详细地解释。
对于具有JSON数据类型的 MySQL 和 PostgreSQL,转换器在org.jooq.JSON和com.fasterxml.jackson.databind.JsonNode之间进行转换,因此它实现了Converter<JSON, JsonNode>接口。当然,你可以将 Jackson 的JsonNode替换为com.google.gson.Gson、javax/jakarta.json.bind.Jsonb等。MySQL 和 PostgreSQL 可用的代码命名为JsonConverterForcedTypes。此应用程序的程序化版本仅适用于 MySQL(但你很容易将其适应其他方言),并命名为ProgrammaticJsonConverter。
对于 Oracle 18c,它没有专门的 JSON 类型(然而,从 Oracle 21c 开始,这种类型是可用的;参见oracle-base.com/articles/21c/json-data-type-21c),对于相对较小的 JSON 数据,通常使用VARCHAR2(4000),而对于大型 JSON 数据,则使用BLOB。在这两种情况下,我们都可以添加一个CHECK ISJSON()约束来确保 JSON 数据的有效性。令人惊讶的是,jOOQ 能够检测到 JSON 数据的存在,并将此类列映射到org.jooq.JSON类型。我们的转换器在org.jooq.JSON和com.fasterxml.jackson.databind.JsonNode之间进行转换。请考虑名为ConverterJSONToJsonNodeForcedTypes的应用程序。
对于没有专门 JSON 类型的 SQL Server,通常使用带有CHECK ISJSON()约束的NVARCHAR。jOOQ 没有检测 JSON 数据使用情况的支持(如 Oracle 的情况),并将此类型映射到String。在这种情况下,我们有一个名为JsonConverterVarcharToJSONForcedTypes的转换器,它在NVARCHAR和org.jooq.JSON之间进行转换,还有一个名为JsonConverterVarcharToJsonNodeForcedTypes的转换器,它在NVARCHAR和JsonNode之间进行转换。
请花时间练习这些示例,以便熟悉 jOOQ 转换器。接下来,让我们来处理 UDT 转换器。
UDT 转换器
如您从 第三章,jOOQ 核心概念 中所知,Oracle 和 PostgreSQL 支持用户定义类型(UDTs),并且在我们的模式中有一个名为 EVALUATION_CRITERIA 的 UDT。这个 UDT 是 MANAGER.MANAGER_EVALUATION 字段的类型,在 Oracle 中看起来像这样:
CREATE OR REPLACE TYPE "EVALUATION_CRITERIA" AS OBJECT (
"communication_ability" NUMBER(7),
"ethics" NUMBER(7),
"performance" NUMBER(7),
"employee_input" NUMBER(7),
// the irrelevant part was skipped
);
我们已经知道 jOOQ 代码生成器自动通过 jooq.generated.udt.EvaluationCriteria 和 jooq...pojos.EvaluationCriteria jooq...udt.EvaluationCriteria.EvaluationCriteriaRecord 记录映射 evaluation_criteria UDT 的字段。
但如果我们假设我们的应用程序需要将此类型作为 JSON 进行操作,那么我们需要一个在 EvaluationCriteriaRecord 和 JSON 类型(例如,Jackson JsonNode)之间进行转换的转换器。JsonConverter 模板如下所示:
public class JsonConverter implements
Converter<EvaluationCriteriaRecord, JsonNode> {
@Override
public JsonNode from(EvaluationCriteriaRecord t) { ... }
@Override
public EvaluationCriteriaRecord to(JsonNode u) { ... }
...
}
接下来,我们配置此转换器,如下所示:
<forcedTypes>
<forcedType>
<userType>com.fasterxml.jackson.databind.JsonNode</userType>
<converter>com...converter.JsonConverter</converter>
<includeExpression>
CLASSICMODELS\.MANAGER\.MANAGER_EVALUATION
</includeExpression>
<includeTypes>EVALUATION_CRITERIA</includeTypes>
</forcedType>
</forcedTypes>
拥有这个集合后,我们可以表达一个 INSERT 语句,如下所示:
JsonNode managerEvaluation = "{...}";
ctx.insertInto(MANAGER,
MANAGER.MANAGER_NAME, MANAGER.MANAGER_EVALUATION)
.values("Mark Joy", managerEvaluation)
.execute();
我们可以表达一个 SELECT 语句,如下所示:
List<JsonNode> managerEvaluation = ctx.select(
MANAGER.MANAGER_EVALUATION)
.from(MANAGER)
.fetch(MANAGER.MANAGER_EVALUATION);
包含的代码命名为 ConverterUDTToJsonNodeForcedTypes,适用于 Oracle 和 PostgreSQL。
自定义数据类型和类型绑定
大概来说,当我们想要将一个类型映射到非标准的 JDBC 类型(一个不在 org.jooq.impl.SQLDataType 中的类型)时,我们需要关注 org.jooq.Binding API,如下面的代码片段所示:
public interface Binding<T, U> extends Serializable { ... }
例如,将非标准的特定供应商的 PostgreSQL HSTORE 数据类型绑定到某些 Java 数据类型(例如,HSTORE 可以非常方便地映射到 Java 的 Map<String, String>)需要利用 Binding API,该 API 包含以下方法(请参阅注释):
// A converter that does the conversion between
// the database type T and the user type U
Converter<T, U> converter();
// A callback that generates the SQL string for bind values of // this binding type. Typically, just ?, but also ?::json, ...
void sql(BindingSQLContext<U> ctx) throws SQLException;
// Register a type for JDBC CallableStatement OUT parameters
ResultSet void register(BindingRegisterContext<U> ctx)
throws SQLException;
// Convert U to a type and set in on a JDBC PreparedStatement
void set(BindingSetStatementContext<U> ctx)
throws SQLException;
// Get a type from JDBC ResultSet and convert it to U
void get(BindingGetResultSetContext<U> ctx)
throws SQLException;
// Get a type from JDBC CallableStatement and convert it to U
void get(BindingGetStatementContext<U> ctx)
throws SQLException;
// Get a value from JDBC SQLInput (useful for Oracle OBJECT)
void get(BindingGetSQLInputContext<U> ctx)
throws SQLException;
// Get a value from JDBC SQLOutput (useful for Oracle OBJECT)
void set(BindingSetSQLOutputContext<U> ctx)
throws SQLException;
例如,让我们考虑我们已经有一个在 Map<String, String> 和 HSTORE 之间名为 HstoreConverter 的 org.jooq.Converter 实现,并且我们继续添加一个名为 HstoreBinding 的 org.jooq.Binding 实现,如下所示:
public class HstoreBinding implements
Binding<Object, Map<String, String>> {
private final HstoreConverter converter
= new HstoreConverter();
@Override
public final Converter<Object, Map<String, String>>
converter() {
return converter;
}
...
}
另一方面,对于 MySQL 的特定供应商 POINT 类型,我们可能有一个名为 PointConverter 的转换器,并且需要一个如下所示的 PointBinding 类——POINT 类型很好地映射到 Java 的 Point2D.Double 类型:
public class PointBinding implements Binding<Object,Point2D> {
private final PointConverter converter
= new PointConverter();
@Override
public final Converter<Object, Point2D> converter() {
return converter;
}
...
}
接下来,我们专注于实现 PostgreSQL 的 HSTORE 和 MySQL 的 POINT 的 Binding SPI。这一过程的一个重要方面是将绑定上下文值转换为 HSTORE 类型。这是在 sql() 方法中完成的,如下所示:
@Override
public void sql(BindingSQLContext<Map<String, String>> ctx)
throws SQLException {
if (ctx.render().paramType() == ParamType.INLINED) {
ctx.render().visit(inline(
ctx.convert(converter()).value())).sql("::hstore");
} else {
ctx.render().sql("?::hstore");
}
}
注意,对于 jOOQ 内联参数(详细信息请参阅 第三章,jOOQ 核心概念),我们不需要渲染占位符(?);因此,我们只渲染 PostgreSQL 特定的语法,::hstore。根据数据库特定的语法,您必须渲染预期的 SQL。例如,对于 PostgreSQL 的 INET 数据类型,您将渲染 ?::inet(或,::inet),而对于 MySQL 的 POINT 类型,您将渲染 ST_PointFromText(?) 如下所示(Point2D 是 java.awt.geom.Point2D):
@Override
public void sql(BindingSQLContext<Point2D> ctx)
throws SQLException {
if (ctx.render().paramType() == ParamType.INLINED) {
ctx.render().sql("ST_PointFromText(")
.visit(inline(ctx.convert(converter()).value()))
.sql(")");
} else {
ctx.render().sql("ST_PointFromText(?)");
}
}
接下来,我们关注为 JDBC CallableStatement 的 OUT 参数注册一个兼容的/合适的类型。通常,VARCHAR 是一个合适的选择(例如,对于 HSTORE、INET 或 JSON 类型,VARCHAR 是一个好的选择)。下面的代码片段展示了代码:
@Override
public void register(BindingRegisterContext
<Map<String, String>> ctx) throws SQLException {
ctx.statement().registerOutParameter(
ctx.index(), Types.VARCHAR);
}
但是,由于默认情况下 MySQL 以二进制数据形式返回 POINT(只要我们不使用任何 MySQL 函数,例如 ST_AsText(g) 或 ST_AsWKT(g) 将几何值从内部几何格式转换为 java.sql.Blob,如下面的代码片段所示):
@Override
public void register(BindingRegisterContext<Point2D> ctx)
throws SQLException {
ctx.statement().registerOutParameter(
ctx.index(), Types.BLOB);
}
接下来,我们将 Map<String, String> 转换为 String 值,并将其设置在 JDBC PreparedStatement 上(对于 MySQL 的 POINT 类型,我们将 Point2D 转换为 String),如下所示:
@Override
public void set(BindingSetStatementContext
<Map<String, String>> ctx) throws SQLException {
ctx.statement().setString(ctx.index(), Objects.toString(
ctx.convert(converter()).value(), null));
}
此外,对于 PostgreSQL 的 HSTORE,我们从 JDBC ResultSet 中获取一个 String 值,并将其转换为 Map<String, String>,如下所示:
@Override
public void get(BindingGetResultSetContext
<Map<String, String>> ctx) throws SQLException {
ctx.convert(converter()).value(
ctx.resultSet().getString(ctx.index()));
}
当我们处理 MySQL 的 POINT 类型时,我们会从 JDBC ResultSet 中获取一个 Blob(或一个 InputStream),并将其转换为 Point2D,如下所示:
@Override
public void get(BindingGetResultSetContext<Point2D> ctx)
throws SQLException {
ctx.convert(converter()).value(ctx.resultSet()
.getBlob(ctx.index())); // or, getBinaryStream()
}
接下来,我们对 JDBC CallableStatement 做同样的事情。对于 HSTORE 类型,我们有以下内容:
@Override
public void get(BindingGetStatementContext
<Map<String, String>> ctx) throws SQLException {
ctx.convert(converter()).value(
ctx.statement().getString(ctx.index()));
}
对于 POINT 类型,我们有以下内容:
@Override
public void get(BindingGetStatementContext<Point2D> ctx)
throws SQLException {
ctx.convert(converter()).value(
ctx.statement().getBlob(ctx.index()));
}
最后,我们重写了 get(BindingGetSQLInputContext<?> bgsqlc) 和 set(BindingSetSQLOutputContext<?> bsqlc) 方法。由于我们不需要 HSTORE/POINT,我们只是抛出一个 SQLFeatureNotSupportedException 异常。为了简洁,我们省略了这段代码。
一旦 Binding 准备就绪,我们必须在 jOOQ 代码生成器中对其进行配置。这相当类似于 Converter 的配置,只是我们使用 <binding/> 标签而不是 <converter/> 标签,如下所示——在这里,我们配置 HstoreBinding(PointBinding 的配置可以在捆绑的代码中找到):
<forcedTypes>
<forcedType>
<userType>java.util.Map<String, String></userType>
<binding>com.classicmodels.binding.HstoreBinding</binding>
<includeExpression>
public\.product\.specs
</includeExpression>
<includeTypes>HSTORE</includeTypes>
</forcedType>
</forcedTypes>
现在,我们可以测试 HstoreBinding。例如,PRODUCT 表有一个名为 SPECS 的字段,其类型为 HSTORE。下面的代码插入了一个带有一些规格的新产品:
ctx.insertInto(PRODUCT, PRODUCT.PRODUCT_NAME,
PRODUCT.PRODUCT_LINE, PRODUCT.SPECS)
.values("2002 Masserati Levante", "Classic Cars",
Map.of("Length (in)", "197", "Width (in)", "77.5",
"Height (in)", "66.1", "Engine", "Twin Turbo
Premium Unleaded V-6"))
.execute();
下面是渲染后的 SQL 看起来的样子:
INSERT INTO "public"."product" (
"product_name", "product_line", "specs")
VALUES (?, ?, ?::hstore)
在解决 ? 占位符之后,SQL 看起来是这样的:
INSERT INTO "public"."product" (
"product_name", "product_line", "specs")
VALUES ('2002 Masserati Levante', 'Classic Cars',
'"Width (in)"=>"77.5", "Length (in)"=>"197",
"Height (in)"=>"66.1",
"Engine"=>"Twin Turbo Premium Unleaded V-6"'::hstore)
在 INSERT(UPDATE、DELETE 等等)时,HstoreConverter 将 Java Map<String, String> 转换为 HSTORE 类型。在 SELECT 时,相同的转换器将 HSTORE 转换为 Map<String, String>。因此,我们的 SELECT 语句可能看起来像这样:
List<Map<String, String>> specs = ctx.select(PRODUCT.SPECS)
.from(PRODUCT)
.where(PRODUCT.PRODUCT_NAME.eq("2002 Masserati Levante"))
.fetch(PRODUCT.SPECS);
注意,我们没有显式地使用任何 Binding 或 Converter,也没有触及HSTORE 类型。对我们来说,在应用程序中,SPECS 的类型是 Map<String, String>。
注意,从 jOOQ 3.15 版本开始,我们可以访问 jOOQ-postgres-extensions 模块(github.com/jOOQ/jOOQ/issues/5507),它也支持 HSTORE。
可以使用 Binding 和 Converter 来编写不同的辅助方法。例如,以下方法可以用来将任何 Param 转换为其数据库数据类型:
static <T> Object convertToDatabaseType(Param<T> param) {
return param.getBinding().converter().to(param.getValue());
}
但是,没有 Binding 会发生什么?是不是一切都丢失了?
不使用 Binding 理解所发生的事情
当 jOOQ 检测到一个没有关联Binding的非标准 JDBC 类型时,它将相应的字段标记为@deprecated Unknown data type,并附带消息,请定义一个明确的{@link org.jooq.Binding}来指定如何处理此类型。可以通过在代码生成器配置中使用{@literal
根据经验法则,依赖Binding是最佳做法,但作为权宜之计,我们也可以为SELECT语句和public static <T> Field<T> field(String sql, Class<T> type, Object... bindings)或更适合的field()变体使用显式映射,对于INSERT、UPDATE等。
然而,在INSERT语句(UPDATE语句等)中使用这样的非标准 JDBC 类型会导致 jOOQ 的SQLDialectNotSupportedException异常,类型 Foo 在方言 Buzz 中不受支持,并且在 SELECT 语句中,会导致 jOOQ 的DataTypeException,没有找到类型 Foo 和 Buzz 的转换器。
您可以检查名为HstoreBinding的应用程序中的此节中的HSTORE示例,以及名为PointGeometryBinding的应用程序中的POINT示例。
此外,捆绑的代码包含用于 PostgreSQL INET类型的InetBinding,用于 PostgreSQL JSON类型的JsonBinding,以及代表 PostgreSQL INET类型Binding程序配置的ProgrammaticInetBinding。接下来,让我们讨论枚举以及如何转换这些枚举。
操作枚举
jOOQ 通过名为org.jooq.EnumType的接口表示 SQL 枚举类型(例如,通过CREATE TYPE创建的 MySQL enum或 PostgreSQL enum数据类型)。每当 jOOQ Java 代码生成器检测到 SQL 枚举类型的用法时,它会自动生成一个实现EnumType的 Java 枚举。例如,SALE表的 MySQL 模式包含以下enum数据类型:
'vat' ENUM ('NONE', 'MIN', 'MAX') DEFAULT NULL
对于vat,jOOQ 生成器渲染了jooq.generated.enums.VatType枚举,如下所示:
public enum VatType implements EnumType {
NONE("NONE"), MIN("MIN"), MAX("MAX");
private final String literal;
private VatType(String literal) {
this.literal = literal;
}
@Override
public Catalog getCatalog() {
return null;
}
@Override
public Schema getSchema() {
return null;
}
@Override
public String getName() {
return "sale_vat";
}
@Override
public String getLiteral() {
return literal;
}
public static VatType lookupLiteral(String literal) {
return EnumType.lookupLiteral(VatType.class, literal);
}
}
默认情况下,此类名称由表名和列名以PascalCase形式组成,这意味着前一个类的名称应该是SaleVat。但每当我们要修改默认名称时,我们可以依靠 jOOQ 生成策略和正则表达式,就像我们在第二章,自定义 jOOQ 参与级别中所做的那样。例如,我们已经通过以下策略将前一个类名称自定义为VatType:
<strategy>
<matchers>
<enums>
<enum>
<expression>sale_vat</expression>
<enumClass>
<expression>VatType</expression>
<transform>AS_IS</transform>
</enumClass>
</enum>
</enums>
</matchers>
</strategy>
拥有这些知识就足以开始编写基于 jOOQ 生成枚举的查询了——例如,向SALE表插入的INSERT语句和从中选择的SELECT语句,如下面的代码片段所示:
import jooq.generated.enums.VatType;
...
ctx.insertInto(SALE, SALE.FISCAL_YEAR, ..., SALE.VAT)
.values(2005, ..., VatType.MAX)
.execute();
List<VatType> vats = ctx.select(SALE.VAT).from(SALE)
.where(SALE.VAT.isNotNull())
.fetch(SALE.VAT);
当然,Sale生成的 POJO(或用户定义的 POJO)和SaleRecord利用了VatType,就像任何其他类型一样。
编写枚举转换器
当 jOOQ 生成的枚举不足以使用时,我们关注枚举转换器。以下是一些可能需要某种枚举转换的场景列表:
-
使用自己的 Java 枚举作为数据库枚举类型
-
使用自己的 Java 枚举作为数据库非枚举类型(或枚举类似类型)
-
使用 Java 非枚举类型作为数据库枚举
-
总是转换为 Java 枚举,偶尔转换为另一个 Java 枚举
为了简化枚举转换任务,jOOQ 提供了一个内置的默认转换器,名为org.jooq.impl.EnumConverter。此转换器可以将VARCHAR值转换为枚举字面量(反之亦然),或将NUMBER值转换为枚举序号(反之亦然)。您也可以显式实例化它,如下所示:
enum Size { S, M, XL, XXL; }
Converter<String, Size> converter
= new EnumConverter<>(String.class, Size.class);
接下来,让我们解决之前列出的枚举场景。
使用自己的 Java 枚举作为数据库枚举类型
在我们的四个数据库中,只有 MySQL 和 PostgreSQL 有针对枚举的专用类型。MySQL 有enum类型,PostgreSQL 有CREATE TYPE foo AS enum( ...)语法。在这两种情况下,jOOQ 都会为我们生成枚举类,但假设我们更愿意使用自己的 Java 枚举。例如,让我们关注SALE表的 MySQL 模式,其中包含这两个枚举:
`rate` ENUM ('SILVER', 'GOLD', 'PLATINUM') DEFAULT NULL
`vat` ENUM ('NONE', 'MIN', 'MAX') DEFAULT NULL
在 PostgreSQL 中,相同的枚举声明如下:
CREATE TYPE rate_type AS enum('SILVER', 'GOLD', 'PLATINUM');
CREATE TYPE vat_type AS enum('NONE', 'MIN', 'MAX');
…
rate rate_type DEFAULT NULL,
vat vat_type DEFAULT NULL,
假设对于vat,我们仍然依赖于 jOOQ 生成的 Java 枚举类(如前文所述,VatType),而对于rate,我们已经编写了以下 Java 枚举:
public enum RateType { SILVER, GOLD, PLATINUM }
为了自动将rate列映射到RateType枚举,我们依赖于<forcedType/>和<enumConverter/>标志标签,如下所示:
<forcedTypes>
<forcedType>
<userType>com.classicmodels.enums.RateType</userType>
<enumConverter>true</enumConverter>
<includeExpression>
classicmodels\.sale\.rate # MySQL
public\.sale\.rate # PostgreSQL
</includeExpression>
<includeTypes>
ENUM # MySQL
rate_type # PostgreSQL
</includeTypes>
</forcedType>
</forcedTypes>
通过启用<enumConverter/>,我们指示 jOOQ 在SALE.RATE被使用时自动应用内置的org.jooq.impl.EnumConverter转换器。完成!从现在起,我们可以将SALE.RATE字段视为RateType类型,jOOQ 将处理映射字段的转换方面(如下列 MySQL 所示),如下所示:
public final TableField<SaleRecord, RateType> RATE
= createField(DSL.name("rate"), SQLDataType.VARCHAR(8),
this, "", new EnumConverter<String, RateType>
(String.class, RateType.class));
命名为SimpleBuiltInEnumConverter的应用程序包含了 MySQL 和 PostgreSQL 的完整示例。
这是一个非常方便的方法,在 MySQL 和 PostgreSQL 中效果相同,但如果我们不使用这种自动转换,我们仍然可以使用我们的RateType Java 枚举手动或显式地使用。让我们看看如何!
首先,我们配置 jOOQ 代码生成器以排除sale_rate(MySQL)/rate_type(PostgreSQL)类型的枚举生成;否则,SALE.RATE字段将自动映射到生成的 Java 枚举。代码如下所示:
<database>
<excludes>
sale_rate (MySQL) / rate_type (PostgreSQL)
</excludes>
</database>
在这种情况下,jOOQ 将 MySQL 中的SALE.RATE映射为String,在 PostgreSQL 中映射为Object。在 PostgreSQL 中,该字段被注释为@deprecated Unknown data type,但我们通过<deprecationOnUnknownTypes/>配置关闭了这种弃用,如下所示:
<generate>
<deprecationOnUnknownTypes>false</deprecationOnUnknownTypes>
</generate>
接下来,在 MySQL 中,我们可以编写如下INSERT语句(SALE.RATE为String类型):
ctx.insertInto(SALE, SALE.FISCAL_YEAR, ..., SALE.RATE)
.values(2005, ..., RateType.PLATINUM.name())
.execute();
我们可以编写如下SELECT语句:
List<RateType> rates = ctx.select(SALE.RATE)
.from(SALE)
.where(SALE.RATE.isNotNull())
.fetch(SALE.RATE, RateType.class);
对于 MySQL 来说,这相当顺畅,但对于 PostgreSQL 来说,则有点棘手。PostgreSQL 的语法要求我们在INSERT时渲染类似?::"public"."rate_type"的内容,如下面的代码片段所示:
ctx.insertInto(SALE, SALE.FISCAL_YEAR, ..., SALE.RATE)
.values(2005, ..., field("?::\"public\".\"rate_type\"",
RateType.PLATINUM.name()))
.execute();
在SELECT时,我们需要将Object显式转换为String,如下面的代码片段所示:
List<RateType> rates = ctx.select(SALE.RATE)
.from(SALE)
.where(SALE.RATE.isNotNull())
.fetch(SALE.RATE.coerce(String.class), RateType.class);
应用程序名为MyEnumBuiltInEnumConverter,其中包含 MySQL 和 PostgreSQL 的完整示例。如果我们不抑制 jOOQ 枚举生成,那么另一种方法是通过扩展 jOOQ 内置的org.jooq.impl.EnumConverter转换器,在 jOOQ 生成的枚举和我们的枚举之间编写一个显式的转换器。当然,这个转换器必须在你的查询中显式调用。你可以在前面提到的应用程序中找到vat枚举的此类示例。
使用您自己的 Java 枚举类型来表示数据库的非枚举类型(或类似枚举的类型)
让我们考虑一个包含只接受某些值的列的遗留数据库,但该列被声明为VARCHAR(或NUMBER)——例如,SALE表有一个TREND字段,其类型为VARCHAR,只接受UP、DOWN和CONSTANT的值。在这种情况下,通过枚举强制使用此字段会更实用,如下所示:
public enum TrendType { UP, DOWN, CONSTANT }
但现在,我们必须处理TrendType和VARCHAR之间的转换。如果我们添加以下<forcedType/>标签(这里针对 Oracle),jOOQ 可以自动完成此操作:
<forcedType>
<userType>com.classicmodels.enums.TrendType</userType>
<enumConverter>true</enumConverter>
<includeExpression>
CLASSICMODELS\.SALE\.TREND
</includeExpression>
<includeTypes>VARCHAR2\(10\)</includeTypes>
</forcedType>
在SimpleBuiltInEnumConverter应用程序中,你可以看到与其他四个数据库的示例并排的完整示例。
由于 SQL Server 和 Oracle 没有枚举类型,我们使用了替代方案。其中之一是,一个常见的替代方案依赖于CHECK约束来实现类似枚举的行为。这些类似枚举的类型可以像之前展示的那样利用<enumConverter/>。这里,它是 Oracle 中的SALE.VAT字段:
vat VARCHAR2(10) DEFAULT NULL
CHECK (vat IN('NONE', 'MIN', 'MAX'))
这里是<forcedType/>标签:
<forcedType>
<userType>com.classicmodels.enums.VatType</userType>
<enumConverter>true</enumConverter>
<includeExpression>
CLASSICMODELS\.SALE\.VAT
</includeExpression>
<includeTypes>VARCHAR2\(10\)</includeTypes>
</forcedType>
如果我们不希望依赖自动转换,那么我们可以使用一个显式的转换器,如下所示:
public class SaleStrTrendConverter
extends EnumConverter<String, TrendType> {
public SaleStrTrendConverter() {
super(String.class, TrendType.class);
}
}
在BuiltInEnumConverter应用程序中,你可以找到与其他四个数据库的示例并排的完整示例。
使用 Java 非枚举类型表示数据库枚举
有时,我们需要一个非枚举类型来表示数据库枚举。例如,假设我们想用一些整数代替VatType枚举(0代表NONE,5代表MIN,19代表MAX),因为我们可能在不同的计算中需要这些整数。也许最好的办法是编写一个像这样的转换器:
public class SaleVatIntConverter
extends EnumConverter<VatType, Integer> { … }
但这不起作用,因为EnumConverter的签名实际上是EnumConverter<T, U extends Enum<U>>类型。显然,Integer不满足这个签名,因为它没有扩展java.lang.Enum,因此我们可以依赖一个常规的转换器(正如你在上一节中看到的),如下所示:
public class SaleVatIntConverter
implements Converter<VatType, Integer> { … }
BuiltInEnumConverter应用包含此示例,以及其他示例。当然,您也可以尝试通过Converter.of()/ofNullable()或 lambda 表达式将此转换器作为内联转换器编写。
总是转换为 Java 枚举,偶尔也转换为另一个 Java 枚举
总是转换为 Java 枚举和偶尔转换为另一个 Java 枚举可能不是一项流行的任务,但让我们用它作为前提来总结我们迄今为止关于枚举转换所学到的东西。
让我们考虑 MySQL 中众所周知的SALE.RATE枚举字段。首先,我们希望始终/自动将SALE.RATE转换为我们的RateType Java 枚举,如下所示:
public enum RateType { SILVER, GOLD, PLATINUM }
为了此目的,我们编写以下<forcedType/>标签:
<forcedType>
<userType>com.classicmodels.enums.RateType</userType>
<enumConverter>true</enumConverter>
<includeExpression>
classicmodels\.sale\.rate
</includeExpression>
<includeTypes>ENUM</includeTypes>
</forcedType>
到目前为止,我们可以在查询中引用SALE.RATE为RateType枚举,但让我们假设我们还有以下StarType枚举:
public enum StarType { THREE_STARS, FOUR_STARS, FIVE_STARS }
基本上,StarType是RateType的替代品(THREE_STARS对应于SILVER,FOUR_STARS对应于GOLD,FIVE_STARS对应于PLATINUM)。现在,我们可能偶尔想在查询中使用StarType而不是RateType,因此我们需要一个转换器,如下所示:
public class SaleRateStarConverter extends
EnumConverter<RateType, StarType> {
public final static SaleRateStarConverter
SALE_RATE_STAR_CONVERTER = new SaleRateStarConverter();
public SaleRateStarConverter() {
super(RateType.class, StarType.class);
}
@Override
public RateType to(StarType u) {
if (u != null) {
return switch (u) {
case THREE_STARS -> RateType.SILVER;
case FOUR_STARS -> RateType.GOLD;
case FIVE_STARS -> RateType.PLATINUM;
};
}
return null;
}
}
由于RateType和StarType不包含相同的字面量,我们必须重写to()方法并定义预期的匹配。完成!
使用RateType的INSERT语句表达如下:
// rely on <forcedType/>
ctx.insertInto(SALE, SALE.FISCAL_YEAR, ,..., SALE.RATE)
.values(2005, ..., RateType.PLATINUM)
.execute();
并且每当我们要使用StarType而不是RateType时,我们就依赖于静态的SALE_RATE_STAR_CONVERTER转换器,如下所示:
// rely on SALE_RATE_STAR_CONVERTER
ctx.insertInto(SALE, SALE.FISCAL_YEAR, ..., SALE.RATE)
.values(2005, ...,
SALE_RATE_STAR_CONVERTER.to(StarType.FIVE_STARS))
.execute();
BuiltInEnumConverter应用包含此示例,以及其他示例。
通过classicmodels\.sale\.rate,我们指定了一个特定的列(CLASSICMODELS.SALE.RATE),但我们可能希望获取此枚举类型的所有列。在这种情况下,SQL 查询比正则表达式更合适。以下是一个 Oracle 的查询示例:
SELECT 'CLASSICMODELS.' || tab.table_name || '.'
|| cols.column_name
FROM sys.all_tables tab
JOIN sys.all_constraints con ON tab.owner = con.owner
AND tab.table_name = con.table_name
JOIN sys.all_cons_columns cols ON cols.owner = con.owner
AND cols.constraint_name = con.constraint_name
AND cols.table_name = con.table_name
WHERE constraint_type = 'C'
AND tab.owner in ('CLASSICMODELS')
AND search_condition_vc
= q'[rate IN('SILVER', 'GOLD', 'PLATINUM')]'
您可以在 MySQL 和 Oracle 中找到此示例,作为BuiltInEnumSqlConverter。
在捆绑的代码中,有更多应用,例如EnumConverter,它提供了枚举的org.jooq.Converter类型的示例;EnumConverterForceTypes,它包含<forcedType/>和枚举示例;以及InsertEnumPlainSql,当不使用 jOOQ 代码生成器时,它包含INSERT和枚举示例。
获取给定枚举数据类型的DataType<T>标签
获取给定枚举数据类型的DataType<T>标签可以像以下三个自解释的示例那样完成:
DataType<RateType> RATETYPE = SALE.RATE.getDataType();
DataType<VatType> VATTYPE
= VARCHAR.asEnumDataType(VatType.class);
DataType<com.classicmodels.enums.VatType> VATTYPE
= VARCHAR.asEnumDataType(jooq.generated.enums.VatType.class)
.asConvertedDataType(VAT_CONVERTER);
现在,您可以使用此数据类型作为任何其他数据类型。接下来,让我们讨论数据类型重写的话题。
数据类型重写
<forcedTypes/>的另一个实用功能是数据类型重写。这允许我们显式选择在 Java 中应使用的 SQL 数据类型(由数据库支持,或不支持但在org.jooq.impl.SQLDataType中存在)。
例如,在 Oracle 中,一个常见的用例是将缺失的BOOLEAN类型映射为NUMBER(1,0)或CHAR(1),如下所示:
CREATE TABLE sale (
...
hot NUMBER(1,0) DEFAULT 0
hot CHAR(1) DEFAULT '1' CHECK (hot IN('1', '0'))
...
}
但这意味着 jOOQ 代码生成器将字段类型NUMBER(1, 0)映射到SQLDataType.TINYINT SQL 数据类型和java.lang.Byte类型,并将类型为CHAR(1)的字段映射到SQLDataType.CHAR SQL 数据类型和String Java 类型。
但 Java 的String类型通常与文本数据处理相关联,而Byte类型通常与二进制数据处理相关联(例如,读取/写入二进制文件),Java 的Boolean类型则清楚地表达了使用标志类型数据的意图。此外,Java 的Boolean类型有一个与SQLDataType.BOOLEAN同源的 SQL 类型(标准 JDBC 类型)。
jOOQ 允许我们强制指定列的类型,因此我们可以强制将SALE.HOT的类型设置为BOOLEAN,如下所示:
<forcedType>
<name>BOOLEAN</name>
<includeExpression>CLASSICMODELS\.SALE\.HOT</includeExpression>
<includeTypes>NUMBER\(1,\s*0\)</includeTypes>
<includeTypes>CHAR\(1\)</includeTypes>
</forcedType>
完成!现在,我们可以将SALE.HOT视为 Java Boolean类型。以下是一个INSERT示例:
ctx.insertInto(SALE, ..., SALE.HOT)
.values(2005,..., Boolean.FALSE)
.execute();
根据NUMBER的精度,jOOQ 将此数据类型映射到BigInteger、Short,甚至Byte(正如您刚才看到的)。如果您觉得使用此类 Java 类型很麻烦,并且知道您的数据更适合Long或Integer类型,那么您有两个选择:相应地调整NUMBER精度,或者依赖 jOOQ 类型重写。当然,您可以将此技术应用于任何其他类型和方言。
一个完整的示例可以在DataTypeRewriting中找到。这个示例的程序版本被称为ProgrammaticDataTypeRewriting。接下来,让我们了解如何处理 jOOQ 的可嵌入类型。
处理可嵌入类型
可嵌入类型是 jOOQ 3.14 版本引入的一个强大功能。大致来说,这个功能通过合成 UDT(用户定义类型)来实现,这些 UDT 可以与 jOOQ 支持的所有数据库一起使用。虽然 PostgreSQL 和 Oracle 支持 UDT(我们可以在数据定义语言(DDL)中直接使用 UDT),但包括 MySQL 和 SQL Server 在内的其他数据库不支持 UDT。但是,通过 jOOQ 的可嵌入类型,我们可以在应用级别使用合成 UDT 与任何数据库一起工作,jOOQ 将负责将这些类型映射到数据库的底层方面。
可嵌入类型通过在生成的org.jooq.EmbeddableRecord中合成地包装一个(通常是多个)数据库列来模拟 UDT。例如,我们可以通过以下 jOOQ 代码生成器中的配置将OFFICE.CITY、OFFICE.STATE、OFFICE.COUNTRY、OFFICE.TERRITORY和OFFICE.ADDRESS_LINE_FIRST包装在名为OFFICE_FULL_ADDRESS的可嵌入类型下(这里针对 MySQL):
<embeddable>
<!-- The optional catalog of the embeddable type -->
<catalog/>
<!-- The optional schema of the embeddable type -->
<schema>classicmodels</schema>
<!-- The name of the embeddable type -->
<name>OFFICE_FULL_ADDRESS</name>
<!-- An optional, defining comment of an embeddable -->
<comment>The full address of an office</comment>
<!-- The name of the reference to the embeddable type -->
<referencingName/>
<!-- An optional, referencing comment of an embeddable -->
<referencingComment/>
我们继续设置匹配表和字段,如下所示:
<!-- A regular expression matching qualified/unqualified
table names to which to apply this embeddable. If left
blank, this will apply to all tables -->
<tables>.*\.office</tables>
<!-- A list of fields to match to an embeddable. Each field
must match exactly one column in each matched table. A
mandatory regular expression matches field names, and
an optional name can be provided to define the
embeddable attribute name. If no name is provided, then
the first matched field's name will be taken -->
<fields>
<field><expression>CITY</expression></field>
<field><expression>ADDRESS_LINE_FIRST</expression></field>
<field><expression>STATE</expression></field>
<field><expression>COUNTRY</expression></field>
<field><expression>TERRITORY</expression></field>
</fields> </embeddable>
接下来,jOOQ 生成jooq...records.OfficeFullAddressRecord,它扩展了EmbeddableRecordImpl和jooq...pojos.OfficeFullAddress。此外,在生成的Office表中,我们观察到一个新的OFFICE_FULL_ADDRESS字段,它可以在以下INSERT语句中使用:
ctx.insertInto(OFFICE, ... ,
OFFICE.ADDRESS_LINE_SECOND, ...)
.values(...,
new OfficeFullAddressRecord("Naples", "Giuseppe
Mazzini", "Campania", "Italy", "N/A"),
...)
.execute();
显然,OFFICE_FULL_ADDRESS列可以在所有类型的语句中使用,包括INSERT、UPDATE、DELETE和SELECT。在这里,它被用在SELECT语句中:
Result<Record1<OfficeFullAddressRecord>> result
= ctx.select(OFFICE.OFFICE_FULL_ADDRESS).from(OFFICE)
.fetch();
或者,它也可以被检索到OfficeFullAddress POJO 中,如下所示:
List<OfficeFullAddress> result
= ctx.select(OFFICE.OFFICE_FULL_ADDRESS).from(OFFICE)
.fetchInto(OfficeFullAddress.class);
在捆绑的代码中,对于 MySQL,我们有EmbeddableType,它包含前面的示例,而对于 PostgreSQL,我们有ProgrammaticEmbeddableType,它是前面示例的程序化版本。
替换字段
到目前为止,我们可以访问(可以使用)可嵌入类型,但我们仍然可以直接访问这个可嵌入类型包装的字段。例如,这些字段可以用在INSERT语句、SELECT语句等中,并且它们出现在集成开发环境(IDE)的自动完成列表中。
替换字段功能意味着向 jOOQ 发出信号,不允许直接访问可嵌入类型的一部分的字段。这些字段将不再出现在 IDE 的自动完成列表中,并且SELECT语句的结果集将不包含这些字段。可以通过<replacesFields/>标志启用此功能,如下所示:
<embeddable>
...
<replacesFields>true</replacesFields>
</embeddable>
EmbeddableTypeReplaceFields应用程序包含 Oracle 的此示例,而ProgrammaticEmbeddableTypeReplaceFields包含 SQL Server 的此示例的程序化版本。
转换可嵌入类型
转换可嵌入类型可以通过org.jooq.Converter完成,就像对任何其他类型一样。例如,在JsonNode和OFFICE_FULL_ADDRESS之间进行转换可以通过一个以如下方式开始的Converter完成:
public class JsonConverter implements
Converter<OfficeFullAddressRecord, JsonNode> {
public static final JsonConverter JSON_CONVERTER
= new JsonConverter();
@Override
public JsonNode from(OfficeFullAddressRecord t) { ... }
@Override
public OfficeFullAddressRecord to(JsonNode u) { ... }
...
}
在这里,这是一个SELECT语句,通过JSON_CONVERTER获取OFFICE.OFFICE_FULL_ADDRESS作为JsonNode:
List<JsonNode> result = ctx.select(OFFICE.OFFICE_FULL_ADDRESS)
.from(OFFICE)
.fetch(OFFICE.OFFICE_FULL_ADDRESS, JSON_CONVERTER);
ConvertEmbeddableType应用程序包含 MySQL 的此示例。
嵌入式域
在 PostgreSQL 中非常流行,域类型是在其他类型之上构建的 UDT,并包含可选约束。例如,在我们的 PostgreSQL 模式中,我们有以下域:
CREATE DOMAIN postal_code AS varchar(15)
CHECK(
VALUE ~ '^\d{5}$'
OR VALUE ~ '^[A-Z]{2}[0-9]{3}[A-Z]{2}$'
);
它在office表中使用,如下所示:
CREATE TABLE office (
...
"postal_code" postal_code NOT NULL,
...
);
如果我们启用此功能,jOOQ 可以为每个域类型生成一个 Java 类型,如下所示:
// Maven and standalone
<database>
...
<embeddableDomains>.*</embeddableDomains>
</database>
// Gradle
database {
embeddableDomains = '.*'
}
// programmatic
withEmbeddableDomains(".*")
虽然.*匹配所有域类型,但你可以使用更严格的正则表达式来精确匹配将被可嵌入类型替换的域。
jOOQ 代码生成器生成一个默认名为PostalCodeRecord(在jooq.generated.embeddables.records中)的可嵌入类型。我们可以用它来创建语义上类型安全的查询,如下例所示:
ctx.select(OFFICE.CITY, OFFICE.COUNTRY)
.from(OFFICE)
.where(OFFICE.POSTAL_CODE.in(
new PostalCodeRecord("AZ934VB"),
new PostalCodeRecord("DT975HH")))
.fetch();
ctx.insertInto(OFFICE, ..., OFFICE.POSTAL_CODE, ...)
.values(..., new PostalCodeRecord("OP909DD"), ...)
.execute();
PostgreSQL 的完整代码命名为Domain。
好吧,我们已经到达了本节的结尾和本章的结尾。请注意,我们故意跳过了可嵌入类型和可嵌入键(包括复合键)的主题,因为这个主题将在第十一章,jOOQ 键中稍后讨论。
摘要
这一章是你在 jOOQ 工具箱中不可或缺的一部分。掌握这里涵盖的主题——例如自定义数据类型、转换器、绑定、数据库厂商特定的数据类型、枚举、可嵌入类型等——将帮助你调整 Java 和数据库数据类型之间的交互,以适应你的复杂场景。在下一章中,我们将介绍数据检索和映射的主题。
第八章:获取和映射
获取结果集并将它们映射成客户端期望的形状和格式是查询数据库时最重要的任务之一。jOOQ 在这一领域表现出色,并提供了一个全面的 API 来获取数据并将其映射到标量、数组、列表、集合、映射、POJO、Java 16 记录、JSON、XML、嵌套集合等。像往常一样,jOOQ API 隐藏了由不同的数据库方言引起的摩擦和挑战,以及将结果集映射到不同数据结构所需的样板代码。在此背景下,我们的议程包括以下主题:
-
简单获取/映射
-
获取单个记录、单个记录或任何记录
-
获取数组、列表、集合和映射
-
获取组
-
通过 JDBC
ResultSet获取 -
获取多个结果集
-
获取关系
-
将 POJO 连接起来
-
jOOQ 记录映射器
-
强大的 SQL/JSON 和 SQL/XML 支持
-
通过惊人的
MULTISET进行嵌套集合 -
懒加载获取
-
异步获取
-
反应式获取
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter08。
简单获取/映射
通过简单的获取/映射,我们指的是你在本书中较早前学到的 jOOQ 获取技术(例如无处不在的into()方法),同时也指新的 jOOQ 实用工具org.jooq.Records。这个实用工具从 jOOQ 3.15 版本开始可用,并包含两种类型的实用方法,正如我们接下来将要讨论的。
收集器方法
收集器方法被命名为intoFoo(),其目标是创建一个收集器(java.util.stream.Collector),用于将记录(org.jooq.Record[N])收集到数组、列表、映射、组等中。这些收集器可以在ResultQuery.collect()中使用,就像任何其他收集器一样。ResultQuery<R>实现了Iterable<R>,并提供了如collect()等便利方法。除了collect()内部处理资源(无需使用 try-with-resources)的事实之外,你还可以使用它进行任何收集器,如标准 JDK 收集器、jOOλ收集器、Records收集器或你自己的收集器。例如,以下是将数据收集到List<String>的示例:
List<String> result = ctx.select(CUSTOMER.CUSTOMER_NAME)
.from(CUSTOMER)
.collect(intoList()); // or, Java's Collectors.toList()
以下是将数据收集到Map<Long, String>的示例:
Map<Long, String> result = ctx.select(
CUSTOMER.CUSTOMER_NUMBER, CUSTOMER.PHONE)
.from(CUSTOMER)
.collect(intoMap());
注意,虽然无处不在的into()方法使用反射,但这些实用工具是纯声明式映射 jOOQ 结果/记录,而不使用反射。
映射方法
映射方法实际上是映射(Function[N])方法的多种风味。映射方法创建一个RecordMapper参数,可以以类型安全的方式将Record[N]映射到另一种类型(例如 POJO 和 Java 16 记录)。例如,你可以按如下方式映射到 Java 记录:
public record PhoneCreditLimit(
String phone, BigDecimal creditLimit) {}
List<PhoneCreditLimit> result = ctx.select(
CUSTOMER.PHONE, CUSTOMER.CREDIT_LIMIT)
.from(CUSTOMER)
.fetch(mapping(PhoneCreditLimit::new));
当映射嵌套行(例如,LEFT JOIN)时,你可以通过将mapping()与Functions.nullOnAllNull(Function1)或Functions.nullOnAnyNull(Function1)结合使用来实现null安全性。以下是一个示例:
List<SalarySale> result = ctx.select(
EMPLOYEE.SALARY, SALE.SALE_)
.from(EMPLOYEE)
.leftJoin(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
.fetch(mapping(nullOnAnyNull(SalarySale::new)));
那么,这是如何工作的呢?例如,当一个员工没有销售(或者你有一个孤儿销售)时,你会得到一个null值,而不是一个SalarySale实例,其中销售为null,SalarySale[salary=120000, sale=null]。
在捆绑代码的Records中,还有许多 MySQL/PostgreSQL 的示例。
简单的获取/映射继续
接下来,让我们看看其他一些可以非常直观和轻松使用的获取/映射数据的技术。由于 jOOQ 手册充满了示例,让我们在本节中尝试突出一些内容。例如,简单的获取可以通过DSLContext.resultQuery()和纯 SQL 完成,如下所示:
Result<Record> result = ctx.resultQuery(
"SELECT customer_name FROM customer").fetch();
List<String> result = ctx.resultQuery(
"SELECT customer_name FROM customer")
.fetchInto(String.class);
List<String> result = ctx.resultQuery(
"SELECT customer_name FROM customer")
.collect(intoList(r -> r.get(0, String.class)));
另一种方法可能依赖于DSLContext.fetch()和纯 SQL,如下所示:
Result<Record> result = ctx.fetch(
"SELECT customer_name FROM customer");
List<String> result = ctx.fetch(
"SELECT customer_name FROM customer").into(String.class);
List<String> result = ctx.fetch(
"SELECT customer_name FROM customer")
.collect(intoList(r -> r.get(0, String.class)));
因此,这个想法非常简单。每次你需要执行一个无法(或不想)通过 jOOQ 生成的基于 Java 的架构表达的纯 SQL 时,只需简单地依靠ResultQuery.collect(collector)或resultQuery() ... fetch()/fetchInto()组合。或者,简单地将其传递给fetch()方法,并调用适当的into()方法或intoFoo()方法,将结果集映射到必要的数据结构。有大量这样的方法可以将结果集映射到标量、数组、列表、集合、映射、POJO、XML 等等。
另一方面,使用基于 Java 的架构(当然,这是推荐的方式)会导致以下不太受欢迎但实用的查询:
List<String> result = ctx.fetchValues(CUSTOMER.CUSTOMER_NAME);
这是一个获取单个字段并获取映射结果(值)的快捷方式,而不需要显式调用into()方法或intoFoo()方法。本质上,当基于 Java 的架构由 jOOQ 生成器生成时,jOOQ 会自动将获取的字段映射到与之关联的 Java 类型。
每次你需要获取单个值时,你可以依靠fetchValue():
Timestamp ts = ctx.fetchValue(currentTimestamp());
<T> T fetchValue(Field<T> field)和<T> List<T> fetchValues(TableField<?,T> tf)方法只是众多可用方法中的一种。查看 jOOQ 文档以了解其余的方法。
然而,既然你已经阅读到这本书的这一部分,我敢肯定你认为这个查询是以下四种更受欢迎的方法的快捷方式:
List<String> result = ctx.select(CUSTOMER.CUSTOMER_NAME)
.from(CUSTOMER).fetch(CUSTOMER.CUSTOMER_NAME);
List<String> result = ctx.select(CUSTOMER.CUSTOMER_NAME)
.from(CUSTOMER).fetchInto(String.class)
List<String> result = ctx.select(CUSTOMER.CUSTOMER_NAME)
.from(CUSTOMER).collect(intoList());
// or, mapping to Result<Record1<String>>
var result = ctx.select(CUSTOMER.CUSTOMER_NAME)
.from(CUSTOMER).fetch();
你是对的,只要你也不考虑以下内容:
List<String> result = ctx.select().from(CUSTOMER)
.fetch(CUSTOMER.CUSTOMER_NAME);
List<String> result = ctx.selectFrom(CUSTOMER)
.fetch(CUSTOMER.CUSTOMER_NAME);
这六个查询都投影了相同的结果,但它们并不相同。作为一个 jOOQ 新手,你可能会做出错误的选择,选择最后两个查询。因此,让我们通过查看生成的 SQL 来澄清这个疑虑。前四个查询生成以下 SQL:
SELECT `classicmodels`.`customer`.`customer_name`
FROM `classicmodels`.`customer`
相比之下,最后两个查询生成以下 SQL:
SELECT `classicmodels`.`customer`.`customer_number`,
`classicmodels`.`customer`.`customer_name`,
...
`classicmodels`.`customer`.`first_buy_date`
FROM `classicmodels`.`customer`
现在,很明显,最后两个查询执行了不必要的操作。我们只需要CUSTOMER_NAME字段,但这些查询会检索所有字段,这是毫无意义的工作,会负面影响性能。在这种情况下,不要责怪 jOOQ 或数据库,因为它们都正好做了你要求的事情!
重要提示
作为一条经验法则,当你不需要检索所有字段时,依赖于前面提到的四种方法,并在SELECT语句中列出必要的字段。在这个背景下,让我再次强调第五章中的SelectOnlyNeededData应用程序,处理不同类型的 SELECT、INSERT、UPDATE、DELETE 和 MERGE 语句。
当您检索多个字段,但不是所有字段时,您应该编写如下代码:
// Result<Record2<String, BigDecimal>>
var result = ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT)
.from(CUSTOMER).fetch();
ExpectedType result = ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT)
.from(CUSTOMER)
.fetchInto(…) // or, collect(), fetch(mapping(…)), ...
现在,让我们考虑另一个基于以下两个 POJOs 的简单检索方法:
class NamePhone {String customerName; String phone;}
class PhoneCreditLimit {String phone; BigDecimal creditLimit;}
通过两个SELECT语句填充这些 POJOs,如下所示:
List<NamePhone> result1 = ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.PHONE)
.from(CUSTOMER).fetchInto(NamePhone.class);
List<PhoneCreditLimit> result2 = ctx.select(
CUSTOMER.PHONE, CUSTOMER.CREDIT_LIMIT)
.from(CUSTOMER).fetchInto(PhoneCreditLimit.class);
然而,在这里,jOOQ 允许我们将Result<Record>映射到多个结果。换句话说,我们可以获得相同的结果并触发单个SELECT语句,如下所示:
// Result<Record3<String, String, BigDecimal>>
var result = ctx.select(CUSTOMER.CUSTOMER_NAME,
CUSTOMER.PHONE, CUSTOMER.CREDIT_LIMIT)
.from(CUSTOMER).fetch();
List<NamePhone> r1=result.into(NamePhone.class);
List<PhoneCreditLimit> r2=result.into(PhoneCreditLimit.class);
太好了!当然,这不仅仅适用于将结果集映射到 POJOs 的情况。在这本书的代码包中,SimpleFetch(适用于 MySQL),您可以看到一个由单个SELECT语句生成的结果集,格式完全为 JSON,其中一部分映射到 Java Set。接下来,让我们深入了解fetchOne()、fetchSingle()和fetchAny()方法。
检索一个记录、单个记录或任何记录
jOOQ 提供了三个方便的方法,名为fetchOne()、fetchSingle()和fetchAny()。这三个方法都能返回一个结果记录,但每个方法都会在特定的坐标下执行此操作。所以,让我们详细地了解每个方法。
使用 fetchOne()
例如,fetchOne()方法最多返回一个结果记录。换句话说,如果检索到的结果集有多条记录,则fetchOne()会抛出 jOOQ 特定的TooManyRowsException异常。但如果结果集没有记录,则fetchOne()返回null。在这种情况下,fetchOne()可以用于通过主键、其他唯一键或保证唯一性的谓词检索记录,同时您准备处理可能为null的结果。以下是一个使用fetchOne()的示例:
EmployeeRecord result = ctx.selectFrom(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchOne();
或者,您可以通过fetchOneInto()直接将数据检索到Employee POJO 中:
Employee result = ctx.selectFrom(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchOneInto(Employee.class);
然而,请注意。记住fetchOneInto(Employee.class)与fetchOne().into(Employee.class)并不相同,因为后者容易抛出NullPointerException异常。因此,最好避免编写如下代码:
Employee result = ctx.selectFrom(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchOne().into(Employee.class);
如果没有具有主键1370的EMPLOYEE POJO,则此代码会抛出 NPE 异常。
此外,避免链式调用component[N]()和value[N]()方法,如下所示(此代码也容易抛出NullPointerException):
String result = ctx.select(EMPLOYEE.EMAIL).from(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchOne().value1();
此外,建议将数据检索到适当的数据类型中(在这里,它是String类型):
String result = ctx.select(EMPLOYEE.EMAIL).from(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchOneInto(String.class);
当然,在使用result之前,仍然需要进行 NPE 检查,但你可以通过Objects.requireNonNullElseGet()来包装这个检查,如下所示:
String result = Objects.requireNonNullElseGet(
ctx.select(EMPLOYEE.EMAIL).from(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchOneInto(String.class), () -> "");
或者,你可以通过 jOOQ 的fetchOptional()方法将其包装成Optional类型:
Optional<EmployeeRecord> result = ctx.selectFrom(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchOptional();
或者,你可能更喜欢fetchOptionalInto():
Optional<Employee> result = ctx.selectFrom(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchOptionalInto(Employee.class);
如往常一样,fetchOne()有多种形式,所有这些都在文档中提供。例如,你可以使用DSLContext.fetchOne()如下所示:
EmployeeRecord result = ctx.fetchOne(EMPLOYEE,
EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L));
或者,你可以根据用户定义的转换器检索记录并进行转换(此转换器在第七章,类型、转换器和绑定)中介绍):
YearMonth result = ctx.select(CUSTOMER.FIRST_BUY_DATE)
.from(CUSTOMER)
.where(CUSTOMER.CUSTOMER_NUMBER.eq(112L))
.fetchOne(CUSTOMER.FIRST_BUY_DATE,
INTEGER_YEARMONTH_CONVERTER);
MySQL 捆绑代码中的FetchOneAnySingle提供了许多其他示例。
使用fetchSingle()
fetchSingle()方法返回确切的一个结果记录。换句话说,如果检索到的结果集包含多个记录,则fetchSingle()会抛出 jOOQ 特定的TooManyRowsException错误。如果没有记录,则抛出 jOOQ 特定的NoDataFoundException错误。
实际上,fetchSingle()与fetchOne()类似,只是在检索到的结果集不包含任何记录时抛出异常而不是返回null。这意味着fetchSingle()在你不期望null结果时,用于通过主键、其他唯一键或保证唯一性的谓词检索记录是有用的。例如,请参阅以下代码块:
Employee result = ctx.selectFrom(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchSingleInto(Employee.class);
或者,你可能只想检索这个员工的电子邮件,如下所示:
String result = ctx.select(EMPLOYEE.EMAIL).from(EMPLOYEE)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L))
.fetchSingle().value1(); // fetchSingleInto(String.class)
捆绑代码中还提供了许多其他示例。
使用fetchAny()
fetchAny()方法返回第一个结果记录。换句话说,如果检索到的结果集包含多个记录,则fetchAny()返回第一个。如果没有记录,则返回null。这与…limit(1).fetchOne();类似。因此,请注意避免任何可能导致抛出NullPointerException异常的使用。以下是一个示例:
SaleRecord result = ctx.selectFrom(SALE)
.where(SALE.EMPLOYEE_NUMBER.eq(1370L))
.fetchAny();
让我们看看另一个示例:
String result = ctx.select(SALE.TREND).from(SALE)
.where(SALE.EMPLOYEE_NUMBER.eq(1370L))
.fetchAnyInto(String.class);
在FetchOneAnySingle中,MySQL 提供了许多其他示例。
检索数组、列表、集合和映射
jOOQ 将检索Result<Record>作为数组、列表、集合或映射所需的代码简化为对其惊人的 API 的简单调用。
检索数组
通过一组全面的 jOOQ 方法可以检索数组,包括fetchArray()(及其变体)、fetchOneArray()、fetchSingleArray()、fetchAnyArray()、fetchArrays()和intoArray()。例如,将所有DEPARTMENT字段作为一个Record数组检索可以如下进行:
Record[] result = ctx.select().from(DEPARTMENT).fetchArray();
与此相比,你可以直接将DEPARTMENT.NAME作为一个String[]来检索,如下所示:
String[] result = ctx.select(DEPARTMENT.NAME).from(DEPARTMENT)
.fetchArray(DEPARTMENT.NAME);
String[] result = ctx.select(DEPARTMENT.NAME).from(DEPARTMENT)
.collect(intoArray(new String[0]));
或者,可以通过 fetchArray(Field<T> field, Converter<? super T,? extends U> cnvrtr) 方法将所有 CUSTOMER.FIRST_BUY_DATE 字段作为 YearMonth 类型的数组检索,如下所示(注意,INTEGER_YEARMONTH_CONVERTER 转换器是在 第七章,类型、转换器和绑定)中引入的):
YearMonth[] result = ctx.select(CUSTOMER.FIRST_BUY_DATE)
.from(CUSTOMER)
.fetchArray(CUSTOMER.FIRST_BUY_DATE,
INTEGER_YEARMONTH_CONVERTER);
你认为将数据库数组检索到 Java 数组中会怎样,比如我们 PostgreSQL 架构中定义的 DEPARTMENT.TOPIC 字段?在这种情况下,结果是 String[][]:
String[][] result = ctx.select(DEPARTMENT.TOPIC)
.from(DEPARTMENT).fetchArray(DEPARTMENT.TOPIC);
如果我们从 Spring Boot REST 控制器返回这个 String[][],结果将是一个 JSON 数组:
[
["publicity", "promotion"],
["commerce","trade","sellout","transaction"],
...
]
那么将一个 UDT 类型检索到 Java 数组中会怎样呢?在我们的 PostgreSQL 架构中,我们有一个 MANAGER.MANAGER_EVALUATION UDT 类型,所以让我们试一试,将它作为数组与 MANAGER_NAME 类型一起检索:
// Record2<String, EvaluationCriteriaRecord>[]
var result = ctx.select(MANAGER.MANAGER_NAME,
MANAGER.MANAGER_EVALUATION)
.from(MANAGER).fetchArray();
让我们打印出第一个经理的姓名及其评估:
System.out.println(result[0].value1()+"\n"
+ result[0].value2().format());
这里是输出(format() 方法将 EvaluationCriteriaRecord 格式化为表格文本):

图 8.1– 打印第一个经理及其评估
最后,让我们尝试将一个可嵌入的类型作为数组检索:
OfficeFullAddressRecord[] result = ctx.select(
OFFICE.OFFICE_FULL_ADDRESS).from(OFFICE)
.fetchArray(OFFICE.OFFICE_FULL_ADDRESS);
OfficeFullAddressRecord[] result = ctx.select(
OFFICE.OFFICE_FULL_ADDRESS).from(OFFICE)
.collect(intoArray(new OfficeFullAddressRecord[0]));
本节中的最后一个示例依赖于 fetchArrays():
Object[][] result = ctx.select(DEPARTMENT.DEPARTMENT_ID,
DEPARTMENT.OFFICE_CODE, DEPARTMENT.NAME)
.from(DEPARTMENT).fetchArrays();
如果我们从 Spring Boot REST 控制器返回这个 Object[][],那么结果将是一个 JSON 数组:
[
[1, "1", "Advertising"],
[2, "1", "Sales"],
[3, "2", "Accounting"],
[4, "3", "Finance"]
]
在捆绑的代码中,你可以找到超过 15 个将 jOOQ 结果作为数组检索的示例。
检索列表和集合
到目前为止,大多数示例都是将结果集检索为 java.util.List 或 org.jooq.Result(即 List 的 jOOQ 包装器),所以关于以下示例如何工作并没有什么神秘之处:
List<String> result = ctx.select(DEPARTMENT.NAME)
.from(DEPARTMENT).fetch(DEPARTMENT.NAME);
List<String> result = ctx.select(DEPARTMENT.NAME)
.from(DEPARTMENT).collect(intoList());
List<Department> result = ctx.select(DEPARTMENT.DEPARTMENT_ID,
DEPARTMENT.OFFICE_CODE, DEPARTMENT.NAME)
.from(DEPARTMENT).fetchInto(Department.class);
那么,让我们关注一些更有趣的案例,比如如何检索我们 PostgreSQL 架构中定义的 DEPARTMENT.TOPIC 数组字段:
List<String[]> result = ctx.select(DEPARTMENT.TOPIC)
.from(DEPARTMENT)
.fetch(DEPARTMENT.TOPIC, String[].class);
我们更倾向于调用 fetch(),它将返回 Result<Record1<String[]>>,而不是调用 fetch(Field<?> field, Class<? extends U> type)。这允许我们返回 List<String[]>。
尝试将 DEPARTMENT.TOPIC 作为 Set<String[]> 检索可以通过 jOOQ 的 fetchSet() 方法完成(查看文档以了解此方法的全部功能):
Set<String[]> result = ctx.select(DEPARTMENT.TOPIC)
.from(DEPARTMENT).fetchSet(DEPARTMENT.TOPIC);
考虑捆绑的代码,其中包含更多检索列表和集合的示例,包括检索 UDT 和可嵌入类型。
检索映射
jOOQ 提供了一套 fetchMap()/intoMap() 方法,允许我们将结果集拆分为 java.util.Map 包装器的键值对。这类方法超过 20 种,但我们主要可以区分 fetchMap(key)/intoMap(Function keyMapper) 方法。这些方法允许我们指定代表键的字段,而值则从 SELECT 结果中推断出来,以及 fetchMap(key, value)/intoMap(Function keyMapper, Function valueMapper) 方法,其中我们分别指定代表键和值的字段。不带任何参数的 Records.intoMap() 方法仅在你有一个两列的 ResultQuery,并且你想将第一列作为键、第二列作为值进行映射时才有用。
例如,让我们获取一个以 DEPARTMENT_ID 作为键(因此是 DEPARTMENT 主键)和 DepartmentRecord 作为值的 Map:
Map<Integer, DepartmentRecord>
result = ctx.selectFrom(DEPARTMENT)
.fetchMap(DEPARTMENT.DEPARTMENT_ID);
Map<Integer, DepartmentRecord>
result = ctx.selectFrom(DEPARTMENT)
.collect(intoMap(r -> r.get(DEPARTMENT.DEPARTMENT_ID)));
或者,让我们指示 jOOQ 将映射值指定为 Department POJO(由 jOOQ 生成)而不是 DepartmentRecord:
Map<Integer, Department> result = ctx.selectFrom(DEPARTMENT)
.fetchMap(DEPARTMENT.DEPARTMENT_ID, Department.class);
你觉得这很令人印象深刻吗?那么,如何映射 CUSTOMER 表和 CUSTOMERDETAIL 表之间的一对一关系?以下是神奇的代码:
Map<Customer, Customerdetail> result = ctx.select()
.from(CUSTOMER)
.join(CUSTOMERDETAIL)
.on(CUSTOMER.CUSTOMER_NUMBER
.eq(CUSTOMERDETAIL.CUSTOMER_NUMBER))
.fetchMap(Customer.class, Customerdetail.class);
为了获得正确的映射,你必须为涉及的 POJO 提供显式的 equals() 和 hashCode() 方法。
仅从 REST 控制器返回这个 Map 将导致以下 JSON 代码:
{
"Customer (99, Australian Home, Paoule, Sart,
40.11.2555, 1370, 21000.00, 20210)":
{
"customerNumber": 99, "addressLineFirst": "43 Rue 2",
"addressLineSecond": null, "city": "Paris", "state":
null, "postalCode": "25017", "country": "France"
},
...
或者,你可能只想使用字段子集来获取这个一对一关系:
Map<Record, Record> result = ctx.select(
CUSTOMER.CONTACT_FIRST_NAME, CUSTOMER.CONTACT_LAST_NAME,
CUSTOMERDETAIL.CITY, CUSTOMERDETAIL.COUNTRY)
.from(CUSTOMER)
.join(CUSTOMERDETAIL)
.on(CUSTOMER.CUSTOMER_NUMBER
.eq(CUSTOMERDETAIL.CUSTOMER_NUMBER))
.fetchMap(new Field[]{CUSTOMER.CONTACT_FIRST_NAME,
CUSTOMER.CONTACT_LAST_NAME},
new Field[]{CUSTOMERDETAIL.CITY,
CUSTOMERDETAIL.COUNTRY});
在捆绑的代码中,ArrListMap(适用于 PostgreSQL)可以查看更多示例,包括映射扁平化的多对一关系、映射数组、UDTs 和可嵌入类型,以及使用 fetchMaps()、fetchSingleMap()、fetchOneMap() 和 fetchAnyMap()。接下来,让我们谈谈获取组。
获取组
jOOQ 的获取组功能与获取映射类似,但它允许我们将记录列表作为每个键值对的值。fetchGroups()、intoGroups() 和 intoResultGroup() 方法有超过 40 种变体;因此,请花时间练习(或者至少阅读关于)它们中的每一个。
我们可以区分 fetchGroups(key) 和 intoGroups(Function keyMapper) 方法,允许我们指定表示键的字段,而值从 SELECT 结果推断为 Result<Record>/List<Record>,以及 fetchGroups(key, value)/intoGroups(Function keyMapper, Function valueMapper) 方法,在这些方法中我们指定表示键和值的字段,这些字段可以是 Result<Record>、List<POJO>、List<scalar> 等。没有参数的 Records.intoGroups() 方法仅在你有一个两列的 ResultQuery,并且你想将第一列映射为键,第二列映射为值时才有用。此外,intoResultGroup() 方法返回一个收集器,它收集一个 jOOQ Record,该 Record 来自 ResultQuery,在 Map 中使用 RecordMapper 参数的结果作为键来收集记录本身到一个 jOOQ Result。
例如,你可以获取所有 OrderRecord 的值,并按客户(CUSTOMER_NUMBER)进行分组,如下所示:
Map<Long, Result<OrderRecord>> result = ctx.selectFrom(ORDER)
.fetchGroups(ORDER.CUSTOMER_NUMBER);
Map<Long, List<OrderRecord>> result = ctx.selectFrom(ORDER)
.collect(intoGroups(r -> r.get(ORDER.CUSTOMER_NUMBER)));
或者,你可以按客户(BANK_TRANSACTION.CUSTOMER_NUMBER)将所有银行转账(BANK_TRANSACTION.TRANSFER_AMOUNT)分组到 Map<Long, List<BigDecimal>> 中:
Map<Long, List<BigDecimal>> result = ctx.select(
BANK_TRANSACTION.CUSTOMER_NUMBER,
BANK_TRANSACTION.TRANSFER_AMOUNT)
.from(BANK_TRANSACTION)
.fetchGroups(BANK_TRANSACTION.CUSTOMER_NUMBER,
BANK_TRANSACTION.TRANSFER_AMOUNT);
Map<Long, List<BigDecimal>> result = ctx.select(
BANK_TRANSACTION.CUSTOMER_NUMBER,
BANK_TRANSACTION.TRANSFER_AMOUNT)
.from(BANK_TRANSACTION)
.collect(intoGroups());
// or, …
.collect(intoGroups(
r -> r.get(BANK_TRANSACTION.CUSTOMER_NUMBER),
r -> r.get(BANK_TRANSACTION.TRANSFER_AMOUNT)));
你可以将它们分组到 Map<Long, List<Record2<Long, BigDecimal>>> 或 Map<Long, Result<Record2<Long, BigDecimal>>> 中,分别:
Map<Long, List<Record2<Long, BigDecimal>>> result
= ctx.select(BANK_TRANSACTION.CUSTOMER_NUMBER,
BANK_TRANSACTION.TRANSFER_AMOUNT)
.from(BANK_TRANSACTION)
.collect(intoGroups(r ->
r.get(BANK_TRANSACTION.CUSTOMER_NUMBER)));
Map<Long, Result<Record2<Long, BigDecimal>>> result
= ctx.select(BANK_TRANSACTION.CUSTOMER_NUMBER,
BANK_TRANSACTION.TRANSFER_AMOUNT)
.from(BANK_TRANSACTION)
.collect(intoResultGroups(r ->
r.get(BANK_TRANSACTION.CUSTOMER_NUMBER)));
如你可能已经直觉到的,fetchGroups() 对于获取和映射一对多关系非常有用。例如,每个产品线(PRODUCTLINE)有多个产品(PRODUCT),我们可以如下获取这些数据:
Map<Productline, List<Product>> result = ctx.select()
.from(PRODUCTLINE)
.innerJoin(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE))
.fetchGroups(Productline.class, Product.class);
从 REST 控制器返回此映射将产生以下 JSON:
{
"Productline (Motorcycles, 599302, Our motorcycles ...)": [
{
"productId": 1,
"productName": "1969 Harley Davidson Ultimate Chopper",
...
},
{
"productId": 3,
"productName": "1996 Moto Guzzi 1100i",
...
},
...
],
"Productline (Classic Cars, 599302 ... )": [
...
]
}
当然,依赖用户定义的 POJOs/Java 记录也是可能的。例如,假设你只需要每个产品线的代码和名称,以及每个产品的产品 ID 和购买价格。拥有名为 SimpleProductline 和 SimpleProduct 的适当 POJO,我们可以映射以下一对多关系:
Map<SimpleProductline, List<SimpleProduct>> result =
ctx.select(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.CODE,
PRODUCT.PRODUCT_ID, PRODUCT.BUY_PRICE)
.from(PRODUCTLINE)
.innerJoin(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE))
.fetchGroups(SimpleProductline.class, SimpleProduct.class);
为了获得正确的映射,你必须为涉及的 POJO 提供显式的 equals() 和 hashCode() 方法。对于 jOOQ 生成的 POJO,这是一个可以通过 <pojosEqualsAndHashCode/> 完成的配置步骤,如下所示:
<generate>
<pojosEqualsAndHashCode>true</pojosEqualsAndHashCode>
</generate>
注意,使用 fetchGroups() 对于 INNER JOIN 是按预期工作的,但对于 LEFT JOIN 则不是。如果获取的父项没有子项,那么你将得到一个包含单个 NULL 项的列表。所以,如果你想使用 LEFT JOIN(至少直到 github.com/jOOQ/jOOQ/issues/11888 被解决),你可以依赖强大的 ResultQuery.collect() 收集器,如下所示:
Map<Productline, List<Product>> result = ctx.select()
.from(PRODUCTLINE)
.leftOuterJoin(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE))
.collect(groupingBy(
r -> r.into(Productline.class),
filtering(
r -> r.get(PRODUCT.PRODUCT_ID) != null,
mapping(
r -> r.into(Product.class),
toList()
)
)
));
这次,没有子项的父项将产生一个空列表。
获取和映射多对多关系也是可能的。我们可以通过 CROSS APPLY(有关更多详细信息,请参阅 第六章,处理不同类型的 JOIN 语句)优雅地完成它。例如,我们通过 OFFICE_HAS_MANAGER 连接表在 OFFICE 和 MANAGER 之间存在多对多关系,并且我们可以通过 fetchGroups() 来映射它,如下所示:
Map<Manager, List<Office>> result = ctx.select().from(MANAGER)
.crossApply(select().from(OFFICE).join(OFFICE_HAS_MANAGER)
.on(OFFICE.OFFICE_CODE
.eq(OFFICE_HAS_MANAGER.OFFICES_OFFICE_CODE))
.where(MANAGER.MANAGER_ID
.eq(OFFICE_HAS_MANAGER.MANAGERS_MANAGER_ID)))
.fetchGroups(Manager.class, Office.class);
将此映射通过 REST 控制器产生必要的 JSON。当然,根据之前的示例,使用连接表映射一对一关系是非常明显的。
然而,请考虑 Lukas Eder 的注意事项:
"在谈论 fetchGroups() 时,我认为总是值得指出,RDBMS 通常也可以使用 ARRAY_AGG()、JSON_ARRAYAGG() 或 XMLAGG() 本地执行此操作。可能性(需要验证)是,这可能会更快,因为需要通过网络传输的数据更少。"
在捆绑代码中,你可以练习更多关于如何使用 fetchGroups() 的示例。该应用程序名为 FetchGroups(适用于 PostgreSQL)。
通过 JDBC ResultSet 获取数据
jOOQ 是一个极其灵活且透明的工具。例如,jOOQ 充当 JDBC ResultSet 的包装器,但也允许我们直接访问它,甚至提供支持以平滑且无痛苦地完成此操作。实际上,我们可以做以下事情:
-
使用 jOOQ 执行
ResultQuery,但返回一个 JDBCResultSet(这依赖于fetchResultSet()方法)。 -
将 jOOQ
Result对象转换为 JDBCResultSet(这依赖于intoResultSet()方法)。 -
使用 jOOQ 从遗留
ResultSet中获取数据。
所有这三个项目都在捆绑代码中得到了示例。然而,在这里,让我们考虑以下 jOOQ 查询开始的第二个项目:
// Result<Record2<String, BigDecimal>>
var result = ctx.select(CUSTOMER.CUSTOMER_NAME,
CUSTOMER.CREDIT_LIMIT).from(CUSTOMER).fetch();
我们理解返回的结果是一个由底层 ResultSet 自动构建的 jOOQ 特定 Result。那么,我们能否反转这个操作,从 jOOQ Result 中获取 ResultSet?是的,我们可以!我们可以通过 intoResultSet() 来做,如下所示:
ResultSet rsInMem = result.intoResultSet();
重要的是要注意,这个魔法在没有数据库活动连接的情况下发生。默认情况下,jOOQ 在获取 jOOQ Result 后关闭数据库连接。这意味着,当我们调用 intoResultSet() 来获取这个内存中的 ResultSet 时,没有数据库的活动连接。jOOQ 将 Result 对象镜像回 ResultSet 而不与数据库交互。接下来,处理这个 ResultSet 是直接的:
while (rsInMem.next()) {
...
}
这很重要,因为通常情况下,只要保持与数据库的开放连接,就可以在 JDBC ResultSet上操作。查看名为 ResultSetFetch 的捆绑应用程序中其他两个项目旁边的完整代码(该应用程序适用于 MySQL)。
获取多个结果集
一些 RDBMS(例如,在 JDBC URL 后附加 allowMultiQueries=true 属性的 SQL Server 和 MySQL)可以返回多个结果集。以下是一个针对 MySQL 的 jOOQ 查询:
ctx.resultQuery(
"SELECT * FROM employee LIMIT 10;
SELECT * FROM sale LIMIT 5");
要在 jOOQ 中获取多个结果集,请调用 fetchMany()。此方法返回 org.jooq.Results 类型的对象,如下面的代码片段所示(注意复数形式以避免与 org.jooq.Result 产生混淆):
Results results = ctx.resultQuery(
"SELECT * FROM employee LIMIT 10;
SELECT * FROM sale LIMIT 5")
.fetchMany();
接下来,你可以将每个结果集映射到其 POJO:
List<Employee> employees =results.get(0).into(Employee.class);
List<Sale> sales = results.get(1).into(Sale.class);
Lukas Eder 表示:
"也许超出了范围,但 Results 类型还允许访问交错更新的计数和异常,这在 T-SQL 数据库(如 SQL Server 或 Sybase)中经常这样做。"
完成!在 FetchMany 应用程序(适用于 MySQL 和 SQL Server)中,你可以查看这个示例,它旁边还有一个示例,该示例从一个结合 DELETE 和 SELECT 的查询中返回两个结果集。
获取关系
我很确信你已经熟悉一对一、一对多和多对多关系。单向一对多的典型映射大致如下:
public class SimpleProductLine implements Serializable {
...
private List<SimpleProduct> products = new ArrayList<>();
}
public class SimpleProduct implements Serializable { ... }
此外,当 SimpleProduct 包含对 SimpleProductLine 的引用时,这被视为一个双向的一对多关系:
public class SimpleProduct implements Serializable {
...
private SimpleProductLine productLine;
}
如果我们有这个 POJO 模型,能否通过 jOOQ API 将相应的结果集映射到它?答案是肯定的,并且可以通过几种方式完成。从你已看到的 fetchInto()、fetchMap() 和 fetchGroups() 方法,到强大的 SQL JSON/XML 映射,以及惊人的 MULTISET 值构造器运算符,jOOQ 提供了如此多的获取模式,几乎不可能找不到解决方案。
无论如何,我们不要偏离主题太远。让我们考虑以下查询:
// Map<Record, Result<Record>>
var map = ctx.select(PRODUCTLINE.PRODUCT_LINE,
PRODUCTLINE.TEXT_DESCRIPTION,PRODUCT.PRODUCT_NAME,
PRODUCT.PRODUCT_VENDOR, PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCTLINE)
.join(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE
.eq(PRODUCT.PRODUCT_LINE))
.orderBy(PRODUCTLINE.PRODUCT_LINE).limit(3)
.fetchGroups(new Field[]{PRODUCTLINE.PRODUCT_LINE,
PRODUCTLINE.TEXT_DESCRIPTION},
new Field[]{PRODUCT.PRODUCT_NAME,
PRODUCT.PRODUCT_VENDOR, PRODUCT.QUANTITY_IN_STOCK});
使用 Map<Record, Result<Record>>(这通常是所有你需要的东西),我们可以填充我们的双向领域模型,如下所示:
List<SimpleProductLine> result = map.entrySet()
.stream()
.map((e) -> {
SimpleProductLine productLine
= e.getKey().into(SimpleProductLine.class);
List<SimpleProduct> products
= e.getValue().into(SimpleProduct.class);
productLine.setProducts(products);
products.forEach(p ->
((SimpleProduct) p).setProductLine(productLine));
return productLine;
}).collect(Collectors.toList());
如果你想要避免通过 fetchGroups() 传递,那么你可以依赖 ResultQuery.collect() 和 Collectors.groupingBy()。这在你想运行 LEFT JOIN 语句时特别有用,因为 fetchGroups() 有以下问题:github.com/jOOQ/jOOQ/issues/11888。另一种方法是来自 ResultSet 的映射。你可以在 OneToOne、OneToMany 和 ManyToMany 应用程序(适用于 MySQL)捆绑的代码中看到这些方法以及其他用于单向/双向一对一和多对多关系的其他方法。
连接 POJO
你已经知道 jOOQ 可以代表我们生成 POJO,它也可以处理用户定义的 POJO。此外,你看到了许多将 jOOQ 结果映射到 POJO 的示例(通常是通过 fetchInto());因此,这不是一个全新的话题。然而,在本节中,让我们更进一步,真正关注 jOOQ 支持的不同类型的 POJO。
如果我们配置的是 <pojos>true</pojos>(此处为 Maven),那么 jOOQ 会生成具有 private 字段、空构造函数、带有参数的构造函数、getter 和 setter 以及 toString() 的 POJO。然而,jOOQ 也可以处理一个非常简单的用户定义 POJO,如下所示:
public class SimplestCustomer {
public String customerName;
public String customerPhone;
}
这里有一个填充此 POJO 的查询:
List<SimplestCustomer> result = ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.PHONE.as("customerPhone"))
.from(CUSTOMER).fetchInto(SimplestCustomer.class);
请注意 as("customerPhone") 别名。这是将 CUSTOMER.PHONE 映射到 POJO 的 customerPhone 字段所必需的;否则,此 POJO 字段将保留为 null,因为 jOOQ 找不到合适的匹配项。另一种方法是添加一个带有参数的构造函数,如下面的 POJO 所示:
public class SimpleDepartment {
private String depName;
private Short depCode;
private String[] depTopic;
public SimpleDepartment(String depName,
Short depCode, String[] depTopic) {
this.depName = depName;
this.depCode = depCode;
this.depTopic = depTopic;
}
...
}
即使 POJO 的字段名称与获取的字段名称不匹配,jOOQ 也会根据此带有参数的构造函数正确填充 POJO:
List<SimpleDepartment> result = ctx.select(
DEPARTMENT.NAME, DEPARTMENT.CODE, DEPARTMENT.TOPIC)
.from(DEPARTMENT).fetchInto(SimpleDepartment.class);
List<SimpleDepartment> result = ctx.select(
DEPARTMENT.NAME, DEPARTMENT.CODE, DEPARTMENT.TOPIC)
.from(DEPARTMENT)
.fetch(mapping(SimpleDepartment::new));
用户定义的 POJO 对于映射包含来自多个表的字段的 jOOQ 结果非常有用。例如,可以使用 POJO 来扁平化一对一关系,如下所示:
public class FlatProductline {
private String productLine;
private Long code;
private String productName;
private String productVendor;
private Integer quantityInStock;
// constructors, getters, setters, toString()
}
此外,这里有一个 jOOQ 查询:
List<FlatProductline> result = ctx.select(
PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.CODE,
PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR,
PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCTLINE)
.join(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE))
.fetchInto(FlatProductline.class);
// .fetch(mapping(FlatProductline::new));
或者,您可以映射 UDTs 和/或可嵌入类型。例如,这里是一个用户定义的 POJO,它获取一个 String 和一个包含 UDT 的可嵌入类型。对于可嵌入类型,我们依赖于 jOOQ 生成的 POJO:
import jooq.generated.embeddables.pojos.ManagerStatus;
public class SimpleManagerStatus {
private Long managerId;
private ManagerStatus ms;
// constructors, getters, setters, toString()
}
此外,jOOQ 查询如下:
List<SimpleManagerStatus> result =
ctx.select(MANAGER.MANAGER_ID, MANAGER.MANAGER_STATUS)
.from(MANAGER).fetchInto(SimpleManagerStatus.class);
在捆绑的代码(即 PojoTypes 应用程序,适用于 PostgreSQL)中还有更多示例。接下来,让我们谈谈 jOOQ 支持的不同类型的 POJO。
POJO 的类型
除了上一节中的典型 POJO 之外,jOOQ 还支持几种其他类型的 POJO。例如,它支持不可变 POJO。
不可变 POJO
用户定义的不可变 POJO 可以编写如下:
public final class ImmutableCustomer {
private final String customerName;
private final YearMonth ym;
// constructor and only getters
}
接下来是一个映射到此 POJO 的 jOOQ 查询:
List<ImmutableCustomer> result = ctx.select(
CUSTOMER.CUSTOMER_NAME,
CUSTOMER.FIRST_BUY_DATE.coerce(YEARMONTH))
.from(CUSTOMER)
.fetchInto(ImmutableCustomer.class);
// .fetch(mapping(ImmutableCustomer::new));
为了按预期工作,不可变 POJO 需要获取的字段与 POJO 的字段(构造函数参数)之间完全匹配。但是,您可以通过 @ConstructorProperties (java.beans.ConstructorProperties) 显式放宽此匹配。请检查捆绑代码(示例 2.2)中的有意义的示例。
jOOQ 可以通过 <generate/> 标签中的以下配置为我们生成不可变 POJO:
<immutablePojos>true</immutablePojos>
此外,它还可以通过以下方式生成 @ConstructorProperties:
<constructorPropertiesAnnotationOnPojos>
true
</constructorPropertiesAnnotationOnPojos>
在捆绑的代码中,与其他示例相邻,您还可以通过用户定义的不可变 POJO 练习映射 UDTs 和可嵌入类型。
装饰有 @Column 的 POJO(jakarta.persistence.Column)
jOOQ 可以将 Result 对象映射到 POJO,如下所示:
public class JpaCustomer {
@Column(name = "customer_name")
public String cn;
@Column(name = "first_buy_date")
public YearMonth ym;
}
如您所见,jOOQ 识别 @Column 注解,并将其用作映射元信息的首选来源:
List<JpaCustomer> result = ctx.select(CUSTOMER.CUSTOMER_NAME,
CUSTOMER.FIRST_BUY_DATE.coerce(YEARMONTH))
.from(CUSTOMER).fetchInto(JpaCustomer.class);
jOOQ 可以通过 <generate/> 中的以下配置生成此类 POJO:
<jpaAnnotations>true</jpaAnnotations>
在捆绑的代码中查看更多示例。
JDK 16 记录
考虑以下 JDK 16 记录:
public record RecordDepartment(
String name, Integer code, String[] topic) {}
jOOQ 查询如下:
List<RecordDepartment> result = ctx.select(
DEPARTMENT.NAME, DEPARTMENT.CODE, DEPARTMENT.TOPIC)
.from(DEPARTMENT)
.fetchInto(RecordDepartment.class);
// .fetch(mapping(RecordDepartment::new));
或者,这里有一个用户定义的 JDK 16 记录以及一个 UDT 类型:
public record RecordEvaluationCriteria(
Integer communicationAbility, Integer ethics,
Integer performance, Integer employeeInput) {}
public record RecordManager(
String managerName, RecordEvaluationCriteria rec) {}
jOOQ 查询如下:
List<RecordManager> result = ctx.select(
MANAGER.MANAGER_NAME, MANAGER.MANAGER_EVALUATION)
.from(MANAGER).fetchInto(RecordManager.class);
或者,你可以使用用户定义的 JDK 16 记录,其中包含可嵌入的类型(在这里,我们使用 jOOQ 生成的 POJO 作为可嵌入的类型):
import jooq.generated.embeddables.pojos.OfficeFullAddress;
public record RecordOffice(
String officecode, OfficeFullAddress ofa) {}
以下是 jOOQ 查询:
List<RecordOffice> result = ctx.select(
OFFICE.OFFICE_CODE, OFFICE.OFFICE_FULL_ADDRESS)
.from(OFFICE).fetchInto(RecordOffice.class);
jOOQ 可以通过以下<generate/>中的配置为我们生成 JDK 16 记录:
<pojosAsJavaRecordClasses>true</pojosAsJavaRecordClasses>
在捆绑的代码中,你可以练习 JDK 16 记录用于 UDT、可嵌入类型等。
接口和抽象类
最后,jOOQ 可以将结果映射到称为“可代理”类型的接口(抽象类)。你可以在捆绑代码和 jOOQ 手册www.jooq.org/doc/latest/manual/sql-execution/fetching/pojos/中找到示例。
此外,jOOQ 可以通过<generate/>标签中的此配置为我们生成接口:
<interfaces>true</interfaces>
如果也生成了 POJO,那么它们将实现这些接口。
POJO 的有用配置
在 POJO 的配置中,我们可以通过<pojosToString/>标志让 jOOQ 不要为 POJO 生成toString()方法,通过<serializablePojos/>标志不要生成可序列化的 POJO(即不实现Serializable),以及通过<fluentSetters/>标志生成流畅的 setter 方法。此外,除了为 Java 生成 POJO,我们还可以通过<pojosAsKotlinDataClasses/>标志让 jOOQ 为 Kotlin 生成 POJO,或者通过<pojosAsScalaCaseClasses/>标志为 Scala 生成 POJO。
此外,在<database/>标签下,我们可以通过<dateAsTimestamp/>强制将LocalDateTime放入 POJO 中,并通过<unsignedTypes/>使用无符号类型。
完整的代码命名为PojoTypes(适用于 PostgreSQL(Maven/Gradle))。在下一节中,让我们讨论记录映射器。
jOOQ 记录映射器
有时,我们需要一个自定义映射,这个映射无法通过fetchInto()方法、fetchMap()方法、fetchGroups()方法或Records实用工具来实现。一个简单的方法是使用Iterable.forEach(Consumer),如下面的映射所示:
ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, EMPLOYEE.EMAIL)
.from(EMPLOYEE)
.forEach((Record3<String, String, String> record) -> {
System.out.println("\n\nTo: "
+ record.getValue(EMPLOYEE.EMAIL));
System.out.println("From: "
+ "hrdepartment@classicmodelcars.com");
System.out.println("Body: \n Dear, "
+ record.getValue(EMPLOYEE.FIRST_NAME)
+ " " + record.getValue(EMPLOYEE.LAST_NAME) + " ...");
});
你可以在ForEachConsumer中查看这个 MySQL 示例。
然而,特别是对于这类情况,jOOQ 提供了一个功能接口,允许我们表达 jOOQ 结果的自定义映射。在这个上下文中,我们有org.jooq.RecordMapper,它返回在将自定义映射应用于 jOOQ 结果后产生的结果。例如,让我们考虑一个通过Builder模式实现的遗留 POJO,其名称为LegacyCustomer:
public final class LegacyCustomer {
private final String customerName;
private final String customerPhone;
private final BigDecimal creditLimit;
…
public static CustomerBuilder getBuilder(
String customerName) {
return new LegacyCustomer.CustomerBuilder(customerName);
}
public static final class CustomerBuilder {
…
public LegacyCustomer build() {
return new LegacyCustomer(this);
}
}
…
}
通过RecordMapper参数将 jOOQ 结果映射到LegacyCustomer可以通过以下方式完成:
List<LegacyCustomer> result
= ctx.select(CUSTOMER.CUSTOMER_NAME, CUSTOMER.PHONE,
CUSTOMER.CREDIT_LIMIT)
.from(CUSTOMER)
.fetch((Record3<String, String, BigDecimal> record) -> {
LegacyCustomer customer = LegacyCustomer.getBuilder(
record.getValue(CUSTOMER.CUSTOMER_NAME))
.customerPhone(record.getValue(CUSTOMER.PHONE))
.creditLimit(record.getValue(CUSTOMER.CREDIT_LIMIT))
.build();
return customer;
});
此示例在捆绑代码中的RecordMapper(适用于 PostgreSQL)中可用,旁边还有其他示例,例如使用RecordMapper参数将 jOOQ 结果映射到最大堆。此外,在第十八章 jOOQ SPI(提供者和监听器)中,你将看到如何通过RecordMapperProvider配置记录映射器,以便 jOOQ 可以自动获取它们。
然而,如果你需要更通用的映射算法,那么我们必须检查一些与 jOOQ 一起工作的第三方库。在排名前三的此类库中,我们有 ModelMapper、SimpleFlatMapper 和 Orika Mapper。
本书范围之外,无法深入探讨所有这些库。因此,我决定选择 SimpleFlatMapper 库 (simpleflatmapper.org/)。让我们假设以下一对多映射:
public class SimpleProductLine {
private String productLine;
private String textDescription;
private List<SimpleProduct> products;
…
}
public class SimpleProduct {
private String productName;
private String productVendor;
private Short quantityInStock;
…
}
实际上,SimpleFlatMapper 可以通过 SelectQueryMapper 映射 jOOQ 结果,如下面的自解释示例所示:
private final SelectQueryMapper<SimpleProductLine> sqMapper;
private final DSLContext ctx;
public ClassicModelsRepository(DSLContext ctx) {
this.ctx = ctx;
this.sqMapper = SelectQueryMapperFactory
.newInstance().newMapper(SimpleProductLine.class);
}
public List<SimpleProductLine> findProductLineWithProducts() {
List<SimpleProductLine> result = sqMapper.asList(
ctx.select(PRODUCTLINE.PRODUCT_LINE,
PRODUCTLINE.TEXT_DESCRIPTION,
PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR,
PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCTLINE)
.innerJoin(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE
.eq(PRODUCT.PRODUCT_LINE))
.orderBy(PRODUCTLINE.PRODUCT_LINE));
return result;
}
在此代码中,SimpleFlatMapper 映射 jOOQ 结果,因此它直接作用于 jOOQ 记录。此代码位于 SFMOneToManySQM 应用程序中(适用于 MySQL)。然而,正如你在 SFMOneToManyJM 应用程序中所见,这个库也可以利用 jOOQ 允许我们操作 ResultSet 对象本身的事实,因此它可以通过名为 JdbcMapper 的 API 直接作用于 ResultSet 对象。这样,SimpleFlatMapper 就绕过了对 Record 的 jOOQ 映射。
此外,捆绑的代码包括与 SFMOneToManyTupleJM 一起映射一对一和多对多关系的应用程序,这是一个结合 SimpleFlatMapper 和 jOOL Tuple2 API 来映射一对多关系而不使用 POJOs 的应用程序。为了简洁,我们无法在书中列出此代码,因此你需要留出一些时间自己探索。
从另一个角度来看,通过相同的 SelectQueryMapper 和 JdbcMapper API,SimpleFlatMapper 库可以与 jOOQ 协同工作,以映射链式和/或嵌套的 JOIN 语句。例如,考虑以下模型:
public class SimpleEmployee {
private Long employeeNumber;
private String firstName;
private String lastName;
private Set<SimpleCustomer> customers;
private Set<SimpleSale> sales;
...
}
public class SimpleCustomer { private String customerName; … }
public class SimpleSale { private Float sale; … }
使用 SimpleFlatMapper 和 jOOQ 的组合,我们可以如下填充此模型:
this.sqMapper = …;
List<SimpleEmployee> result = sqMapper.asList(
ctx.select(EMPLOYEE.EMPLOYEE_NUMBER, EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, CUSTOMER.CUSTOMER_NAME, SALE.SALE_)
.from(EMPLOYEE)
.leftOuterJoin(CUSTOMER)
.on(CUSTOMER.SALES_REP_EMPLOYEE_NUMBER
.eq(EMPLOYEE.EMPLOYEE_NUMBER))
.leftOuterJoin(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER
.eq(SALE.EMPLOYEE_NUMBER))
.where(EMPLOYEE.OFFICE_CODE.eq(officeCode))
.orderBy(EMPLOYEE.EMPLOYEE_NUMBER));
完整的代码命名为 SFMMultipleJoinsSQM。使用 JdbcMapper 的此代码版本命名为 SFMMultipleJoinsJM。此外,在捆绑的代码中,你可以找到一个映射深层次 JOIN 的示例,该 JOIN 类型为 (EMPLOYEE has CUSTOMER has ORDER has ORDERDETAIL has PRODUCT)。此 JOIN 还在 SFMMultipleJoinsInnerLevelsTupleJM 中使用 jOOL Tuple2 和没有 POJOs 进行映射。无论如何,即使这些事情可行,我也不建议你在实际应用程序中这样做。你最好依靠 SQL/JSON/XML 操作符或 MULTISET,就像你稍后将要做的那样。
同样,为了简洁,我们无法在书中列出此代码,因此你需要留出一些时间自己探索。到目前为止,我们已经达到了本章的高潮。现在是时候敲锣打鼓了,因为下一节将涵盖 jOOQ SQL/JSON 和 SQL/XML 的出色映射支持。
强大的 SQL/JSON 和 SQL/XML 支持
从 jOOQ 3.14 版本开始,我们支持将结果集映射到任何可以通过 JSON 或 XML 塑造的层次/嵌套结构。例如,如果您开发 REST API,您可以在不映射任何内容到您的领域模型的情况下返回所需形状的 JSON/XML 数据。
如您所知,大多数 RDBMS 支持 SQL/JSON(标准或供应商特定),其中一些还支持 SQL/XML。
处理 SQL/JSON 支持
简而言之,对于 SQL/JSON,我们可以讨论以下在 org.jooq.impl.DSL 类中有 jOOQ 实现的操作符:
-
JSON_OBJECT(DSL.jsonObject()、DSL.jsonEntry()),JSON_ARRAY(DSL.jsonArray()), 和JSON_VALUE(DSL.jsonValue()) 用于从值构建 JSON 数据 -
JSON_ARRAYAGG(DSL.jsonArrayAgg()) 和JSON_OBJECTAGG(DSL.jsonObjectAgg()) 用于将数据聚合到嵌套的 JSON 文档中 -
JSON_EXISTS(DSL.jsonExists()) 用于通过 JSON 路径查询文档 -
JSON_TABLE(DSL.jsonTable()) 用于将 JSON 值转换为 SQL 表 -
SQL Server 的
FOR JSON语法(包括ROOT、PATH、AUTO和WITHOUT_ARRAY_WRAPPER);jOOQ 商业版模拟了不支持该语法的数据库的FOR JSON语法(在本章中,您可以查看 SQL Server 和 Oracle 的示例)
让我们通过 jOOQ DSL API 看一些这些操作符的入门示例。
从值构建和聚合 JSON 数据
通过 JSON_OBJECT 操作符可以从值构建 JSON 数据。这在 jOOQ 中通过 DSL.jsonObject() 方法的不同变体实现。例如,您可以将 CUSTOMER.CUSTOMER_NAME 和 CUSTOMER.CREDIT_LIMIT 字段映射到 org.jooq.JSON 对象,如下所示:
Result<Record1<JSON>> result = ctx.select(jsonObject(
key("customerName").value(CUSTOMER.CUSTOMER_NAME),
key("creditLimit").value(CUSTOMER.CREDIT_LIMIT))
.as("json_result"))
.from(CUSTOMER).fetch();
与 key().value() 构造相比,我们可以使用 jsonObject(JSON Entry<?>... entries),如下所示:
Result<Record1<JSON>> result = ctx.select(jsonObject(
jsonEntry("customerName", CUSTOMER.CUSTOMER_NAME),
jsonEntry("creditLimit", CUSTOMER.CREDIT_LIMIT))
.as("json_result"))
.from(CUSTOMER).fetch();
返回的 Result 对象(请记住,这是一个 java.util.List 的包装器)的大小等于获取的客户的数量。每个 Record1 对象包装了一个 org.jooq.JSON 实例,代表客户名称和信用额度。如果我们只想将此 Result 对象格式化为 JSON,我们可以调用 formatJSON() 方法(这将在下一章中介绍)。这将返回一个简单的格式化表示,如下所示:
System.out.println(result.formatJSON());
{
"fields": [{"name": "json_result", "type": "JSON"}],
"records":
[
[{"creditLimit": 21000, "customerName": "Australian Home"}],
[{"creditLimit": 21000, "customerName": "Joliyon"}],
[{"creditLimit": 21000, "customerName": "Marquez Xioa"}]
…
]
}
然而,这个响应太冗长了,不适合发送给客户端。例如,您可能只需要 "records" 键。在这种情况下,我们可以依赖 formatJSON(JSONFormat) 变体,如下所示:
System.out.println(
result.formatJSON(JSONFormat.DEFAULT_FOR_RECORDS));
[
[{"creditLimit": 50000.00, "customerName":"GOLD"}],
[{"creditLimit": null, "customerName": "Australian Home"}],
[{"creditLimit": null, "customerName": "Joliyon"}],
...
]
假设您只想发送第一个 JSON 数组,您可以从 Result 对象中提取它作为 result.get(0).value1().data():
result.get(0) // 0-first JSON, 1-second JSON, 2-third JSON …
.value1() // this is the value from Record1, a JSON
.data() // this is the data of the first JSON as String
{"creditLimit": 21000.00, "customerName": "Australian Home"}
然而,也许您计划将所有这些 JSON 作为 List<String> 发送到客户端。那么,依赖 fetchInto(String.class),它将返回所有 JSON 作为 List<String>。请注意,每个 String 都是一个 JSON:
List<String> result = ctx.select(jsonObject(
jsonEntry("customerName", CUSTOMER.CUSTOMER_NAME),
jsonEntry("creditLimit", CUSTOMER.CREDIT_LIMIT))
.as("json_result"))
.from(CUSTOMER).fetchInto(String.class);
此外,您还可以将响应作为 JSON 数组的列表发送。只需通过 jsonArray() 将每个 JSON 对象包装到数组中,如下所示:
List<String> result = ctx.select(jsonArray(jsonObject(
jsonEntry("customerName", CUSTOMER.CUSTOMER_NAME),
jsonEntry("creditLimit", CUSTOMER.CREDIT_LIMIT)))
.as("json_result"))
.from(CUSTOMER).fetchInto(String.class);
这次,第一个 JSON 数组(在列表中的索引 0)是 [{"creditLimit": 21000.00, "customerName": "Australian Home"}],第二个(在列表中的索引 1)是 [{"creditLimit": 21000, "customerName": "Joliyon"}],依此类推。
然而,将这些 JSON 聚合到一个单独的数组中更为实用。这可以通过 jsonArrayAgg() 实现,它将返回一个包含所有获取数据的单个 JSON 数组:
String result = ctx.select(jsonArrayAgg(jsonObject(
jsonEntry("customerName", CUSTOMER.CUSTOMER_NAME),
jsonEntry("creditLimit", CUSTOMER.CREDIT_LIMIT)))
.as("json_result"))
.from(CUSTOMER).fetchSingleInto(String.class);
聚合的 JSON 数组如下所示:
[
{"creditLimit": 21000,"customerName": "Australian Home"},
{"creditLimit": 21000,"customerName": "Joliyon"},
...
]
然而,我们也可以将获取的数据聚合为一个单独的 JSON 对象,其中 CUSTOMER_NAME 作为键,CREDIT_LIMIT 作为值。这可以通过 jsonObjectAgg() 方法完成,如下所示:
String result = ctx.select(jsonObjectAgg(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT)
.as("json_result"))
.from(CUSTOMER).fetchSingleInto(String.class);
这次,生成的 JSON 结果如下:
{
"Joliyon": 21000,
"Falafel 3": 21000,
"Petit Auto": 79900,
…
}
如果您是 SQL Server 的粉丝,那么您知道可以通过非标准的 FOR JSON 语法将数据作为 JSON 获取。jOOQ 通过 forJson() API 支持这种语法。它还支持 ROOT 通过 root(),PATH 通过 path(),AUTO 通过 auto(),以及 WITHOUT_ARRAY_WRAPPER 通过 withoutArrayWrapper() 等子句。以下是一个通过 PATH 使用点分隔的列名生成嵌套结果的示例:
Result<Record1<JSON>> result = ctx.select(
CUSTOMER.CONTACT_FIRST_NAME, CUSTOMER.CREDIT_LIMIT,
PAYMENT.INVOICE_AMOUNT.as("Payment.Amount"),
PAYMENT.CACHING_DATE.as("Payment.CachingDate"))
.from(CUSTOMER)
.join(PAYMENT)
.on(CUSTOMER.CUSTOMER_NUMBER.eq(PAYMENT.CUSTOMER_NUMBER))
.orderBy(CUSTOMER.CREDIT_LIMIT).limit(5)
.forJSON().path().root("customers")
.fetch();
以下是一个使用 AUTO 的示例,它将自动根据 SELECT 语句的结构生成输出:
Result<Record1<JSON>> result = ctx.select(
CUSTOMER.CONTACT_FIRST_NAME, CUSTOMER.CREDIT_LIMIT,
PAYMENT.INVOICE_AMOUNT, PAYMENT.CACHING_DATE)
.from(CUSTOMER)
.join(PAYMENT)
.on(CUSTOMER.CUSTOMER_NUMBER.eq(PAYMENT.CUSTOMER_NUMBER))
.orderBy(CUSTOMER.CREDIT_LIMIT).limit(5)
.forJSON().auto().withoutArrayWrapper().fetch();
您可以在 SimpleJson 的捆绑代码中查看这些示例,并熟悉生成的 JSON。现在,让我们谈谈使用 SQL 标准的 JSON 运算符(对于 SQL Server 的 FOR JSON 语法,请考虑前两个示例)对结果 JSON 的内容进行排序和限制。
使用 ORDER BY 和 LIMIT
当我们不使用聚合运算符时,排序和限制与常规查询非常相似。例如,您可以按 CUSTOMER_NAME 排序,并将结果限制为三个 JSON,如下所示:
List<String> result = ctx.select(jsonObject(
key("customerName").value(CUSTOMER.CUSTOMER_NAME),
key("creditLimit").value(CUSTOMER.CREDIT_LIMIT))
.as("json_result"))
.from(CUSTOMER)
.orderBy(CUSTOMER.CUSTOMER_NAME).limit(3)
.fetchInto(String.class);
另一方面,当涉及聚合运算符(jsonArrayAgg() 和 jsonObjectAgg())时,应该在聚合之前进行限制(例如,在子查询、JOIN 等)。否则,此操作将应用于结果聚合本身,而不是聚合数据。在聚合过程中,排序可以在限制之前进行。例如,在以下示例中,子查询按 CUSTOMER_NAME 对客户进行排序,并将返回的结果限制为 3,而聚合则按 CREDIT_LIMIT 对此结果进行排序:
String result = ctx.select(jsonArrayAgg(jsonObject(
jsonEntry("customerName", field("customer_name")),
jsonEntry("creditLimit", field("credit_limit"))))
.orderBy(field("credit_limit")).as("json_result"))
.from(select(CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT)
.from(CUSTOMER).orderBy(CUSTOMER.CUSTOMER_NAME).limit(3))
.fetchSingleInto(String.class);
聚合结果按 CREDIT_LIMIT 排序:
[
{"creditLimit": 0,"customerName": "American Souvenirs Inc"},
{"creditLimit": 61100,"customerName": "Alpha Cognac"},
{"creditLimit": 113000,"customerName": "Amica Models & Co."}
]
更多示例可以在 SimpleJson 的捆绑代码中找到。请注意,在使用 PostgreSQL 和 Oracle 的应用程序中,您可以看到 SQL 标准的 NULL ON NULL (nonOnNull()) 和 ABSENT ON NULL (absentOnNull()) 语法在起作用。现在,让我们使用 JSON 路径查询文档。
使用 JSON 路径查询 JSON 文档
通过JSON_EXISTS和JSON_VALUE,我们可以查询和构建依赖于 JSON 路径的 JSON 文档。为了练习 jOOQ 的jsonExists()和jsonValue()查询,让我们考虑存储在 JSON 格式的MANAGER.MANAGER_DETAIL字段,以便您熟悉其结构和内容。
现在,选择既是经理又是股东(在 JSON 中有"shareholder"键)的经理的MANAGER.MANAGER_ID和MANAGER.MANAGER_NAME字段可以通过jsonExists()和 JSON 路径完成,如下所示:
Result<Record2<Long, String>> result = ctx.select(
MANAGER.MANAGER_ID, MANAGER.MANAGER_NAME)
.from(MANAGER)
.where(jsonExists(MANAGER.MANAGER_DETAIL, "$.shareholder"))
.fetch();
如果获取的 JSON 是由 JSON 值构建的,那么我们应该依赖于jsonValue()和 JSON 路径。例如,获取所有经理的城市可以通过以下方式完成:
Result<Record1<JSON>> result = ctx.select(
jsonValue(MANAGER.MANAGER_DETAIL, "$.address.city")
.as("city"))
.from(MANAGER).fetch();
通过结合jsonExists()和jsonValue(),我们可以从 JSON 文档中查询和构建 JSON 结果。例如,在 PostgreSQL 和 Oracle 中,我们可以通过利用 JSON 路径选择担任Principal Manager角色的经理的电子邮件:
Result<Record1<JSON>> result = ctx.select(
jsonValue(MANAGER.MANAGER_DETAIL, "$.email").as("email"))
.from(MANAGER)
.where(jsonExists(MANAGER.MANAGER_DETAIL,
"$[*] ? (@.projects[*].role == \"Principal Manager\")"))
.fetch();
更多示例可以在附带代码SimpleJson中找到。接下来,让我们处理JSON_TABLE。
将 JSON 值转换为 SQL 表
通过JSON_TABLE运算符可以将 JSON 值转换为 SQL 表,在 jOOQ 中,这相当于jsonTable()方法。例如,让我们通过jsonTable(Field<JSON> json, Field<String> path)方式构建一个包含所有开发类型项目的 SQL 表:
Result<Record> result = ctx.select(table("t").asterisk())
.from(MANAGER, jsonTable(MANAGER.MANAGER_DETAIL,
val("$.projects[*]"))
.column("id").forOrdinality()
.column("name", VARCHAR).column("start", DATE)
.column("end", DATE).column("type", VARCHAR)
.column("role", VARCHAR).column("details", VARCHAR).as("t"))
.where(field("type").eq("development")).fetch();
此查询将生成一个表,如下所示:

图 8.2 – 上一个查询的结果
一旦您获取了一个 SQL 表,您就可以像处理任何其他数据库表一样思考和行动。为了简洁,我简单地使用了VARCHAR,但最好指定一个大小,以避免默认为VARCHAR(max)。
包含如何使用JSON_TABLE进行聚合、ORDER BY、LIMIT以及如何从 SQL 表转换回 JSON 的更多示例可以在附带代码SimpleJson中找到。
通过 SQL/JSON 处理关系
通过 SQL/JSON 支持,可以轻松地构建已知的单一到单一、单一到多和多变多的关系。
映射关系到 JSON
因此,如果您有任何关于 jOOQ 在映射关系方面存在不足的感觉,那么您将非常高兴地看到一对一关系可以轻松地直接转换为 JSON,如下所示(在这种情况下,我们正在查看PRODUCTLINE和PRODUCT之间的关系):
Result<Record1<JSON>> result = ctx.select(jsonObject(
key("productLine").value(PRODUCTLINE.PRODUCT_LINE),
key("textDescription").value(PRODUCTLINE.TEXT_DESCRIPTION),
key("products").value(select(jsonArrayAgg(
jsonObject(key("productName").value(PRODUCT.PRODUCT_NAME),
key("productVendor").value(PRODUCT.PRODUCT_VENDOR),
key("quantityInStock").value(PRODUCT.QUANTITY_IN_STOCK)))
.orderBy(PRODUCT.QUANTITY_IN_STOCK))
.from(PRODUCT)
.where(PRODUCTLINE.PRODUCT_LINE
.eq(PRODUCT.PRODUCT_LINE)))))
.from(PRODUCTLINE).orderBy(PRODUCTLINE.PRODUCT_LINE)
.fetch();
如您从前面的代码中可以推断出,表达一对一和多对多关系只是简单地使用 SQL/JSON 运算符的问题。您可以在JsonRelationships的附带代码中找到这些示例,包括如何使用JOIN代替SELECT子查询。
如果你认为 Result<Record1<JSON>> 还没有准备好发送到客户端(例如,通过 REST 控制器),那么通过聚合所有产品行到一个 JSON 数组并依赖 fetchSingleInto() 来稍微装饰一下:
String result = ctx.select(
jsonArrayAgg(jsonObject(…))
…
.orderBy(PRODUCTLINE.PRODUCT_LINE))
.from(PRODUCTLINE).fetchSingleInto(String.class);
在 SQL Server 中,我们可以通过 forJson() 获取类似的结果:
Result<Record1<JSON>> result = ctx.select(
PRODUCTLINE.PRODUCT_LINE.as("productLine"),
PRODUCTLINE.TEXT_DESCRIPTION.as("textDescription"),
select(PRODUCT.PRODUCT_NAME.as("productName"),
PRODUCT.PRODUCT_VENDOR.as("productVendor"),
PRODUCT.QUANTITY_IN_STOCK.as("quantityInStock"))
.from(PRODUCT)
.where(PRODUCT.PRODUCT_LINE.eq(PRODUCTLINE.PRODUCT_LINE))
.orderBy(PRODUCT.QUANTITY_IN_STOCK)
.forJSON().path().asField("products"))
.from(PRODUCTLINE)
.orderBy(PRODUCTLINE.PRODUCT_LINE).forJSON().path().fetch();
或者我们可以通过 formatJSON(JSONformat) 获取一个 String:
String result = ctx.select(
PRODUCTLINE.PRODUCT_LINE.as("productLine"),
...
.forJSON().path()
.fetch()
.formatJSON(JSONFormat.DEFAULT_FOR_RECORDS);
这两个示例都将生成一个 JSON,如下所示(如你所见,可以通过 as("alias") 使用别名来更改从字段名推断出的默认 JSON 键):
[
{
"productLine": "Classic Cars",
"textDescription": "Attention car enthusiasts...",
"products": [
{
"productName": "1968 Ford Mustang",
"productVendor": "Autoart Studio Design",
"quantityInStock": 68
},
{
"productName": "1970 Chevy Chevelle SS 454",
"productVendor": "Unimax Art Galleries",
"quantityInStock": 1005
}
...
]
},
{
"productLine": "Motorcycles", ...
}
]
在 JsonRelationships 的捆绑代码中,你可以找到很多关于一对一、一对多和多对多关系的示例。此外,你还可以查看如何将数组和 UDT 映射到 JSON 的几个示例。
将 JSON 关系映射到 POJOs
正如你所看到的,jOOQ 可以直接将关系提取并映射到 JSON。然而,这还不是全部!jOOQ 可以更进一步,将生成的 JSON 映射到领域模型(POJOs)。是的,你没听错;只要我们在类路径上有 Gson、Jackson(Spring Boot 默认包含)或 JAXB,jOOQ 就可以自动将查询结果映射到我们的嵌套数据结构。这在实际上不需要 JSON 本身时非常有用——你只需依赖 JSON 来促进嵌套数据结构的构建,并将它们映射回 Java。例如,假设以下领域模型:
public class SimpleProductLine {
private String productLine;
private String textDescription;
private List<SimpleProduct> products;
}
public class SimpleProduct {
private String productName;
private String productVendor;
private Short quantityInStock;
}
我们能否仅使用 jOOQ 从 Result 中填充这个模型?是的,我们可以通过 SQL/JSON 支持来实现,如下所示:
List<SimpleProductLine> result = ctx.select(jsonObject(
key("productLine").value(PRODUCTLINE.PRODUCT_LINE),
key("textDescription").value(PRODUCTLINE.TEXT_DESCRIPTION),
key("products").value(select(jsonArrayAgg(jsonObject(
key("productName").value(PRODUCT.PRODUCT_NAME),
key("productVendor").value(PRODUCT.PRODUCT_VENDOR),
key("quantityInStock").value(PRODUCT.QUANTITY_IN_STOCK)))
.orderBy(PRODUCT.QUANTITY_IN_STOCK)).from(PRODUCT)
.where(PRODUCTLINE.PRODUCT_LINE
.eq(PRODUCT.PRODUCT_LINE)))))
.from(PRODUCTLINE)
.orderBy(PRODUCTLINE.PRODUCT_LINE)
.fetchInto(SimpleProductLine.class);
这真是太酷了,对吧?!对于一对一和多对多关系,你也可以在捆绑代码中看到同样的效果。所有示例都可在 JsonRelationshipsInto 中找到。
映射任意模型
如果你认为你刚刚看到的是令人印象深刻的,那么准备好更多,因为 jOOQ 可以获取并映射几乎任何类型的任意模型,而不仅仅是众所周知的 1:1、1:n 和 n:n 关系。让我们考虑以下三个任意模型:

图 8.3 – 任意领域模型
你会选择哪一个在书中作为示例?第二个(jsonObject() 和 jsonArrayAgg(),如下所示):
Result<Record1<JSON>> result = ctx.select(jsonObject(
jsonEntry("customerName", CUSTOMER.CUSTOMER_NAME),
jsonEntry("creditLimit", CUSTOMER.CREDIT_LIMIT),
jsonEntry("payments", select(jsonArrayAgg(jsonObject(
jsonEntry("customerNumber", PAYMENT.CUSTOMER_NUMBER),
jsonEntry("invoiceAmount", PAYMENT.INVOICE_AMOUNT),
jsonEntry("cachingDate", PAYMENT.CACHING_DATE),
jsonEntry("transactions", select(jsonArrayAgg(jsonObject(
jsonEntry("bankName", BANK_TRANSACTION.BANK_NAME),
jsonEntry("transferAmount",
BANK_TRANSACTION.TRANSFER_AMOUNT)))
.orderBy(BANK_TRANSACTION.TRANSFER_AMOUNT))
.from(BANK_TRANSACTION)
.where(BANK_TRANSACTION.CUSTOMER_NUMBER
.eq(PAYMENT.CUSTOMER_NUMBER)
.and(BANK_TRANSACTION.CHECK_NUMBER
.eq(PAYMENT.CHECK_NUMBER))))))
.orderBy(PAYMENT.CACHING_DATE))
.from(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))),
jsonEntry("details", select(
jsonObject(jsonEntry("city", CUSTOMERDETAIL.CITY),
jsonEntry("addressLineFirst",
CUSTOMERDETAIL.ADDRESS_LINE_FIRST),
jsonEntry("state", CUSTOMERDETAIL.STATE)))
.from(CUSTOMERDETAIL)
.where(CUSTOMERDETAIL.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER)))))
.from(CUSTOMER).orderBy(CUSTOMER.CREDIT_LIMIT).fetch();
Lukas Eder 表示:
"关于这一点,我总是喜欢提到的是,没有偶然的笛卡尔积或昂贵的去重操作(如 JPA),因为所有数据已经正确嵌套在 SQL 中,并且以最佳方式传输。这种方法应该是使用 SQL 嵌套集合或为某些前端生成 JSON/XML 的首选方法。永远不要使用普通的连接,这些连接应该仅用于扁平结果或聚合。"
仔细分析这个查询,并查看捆绑代码以查看输出。此外,在捆绑代码中,你还可以练习 模型 1 和 模型 3。对于这些模型中的每一个,你都有 JSON 结果和相应的 POJO 映射。应用程序名为 NestedJson。
我相信,作为一个 SQL Server 粉丝,您迫不及待地想看到通过 forJson() 表达的先前查询版本,所以这就是它:
Result<Record1<JSON>> result = ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT,
select(PAYMENT.CUSTOMER_NUMBER, PAYMENT.INVOICE_AMOUNT,
PAYMENT.CACHING_DATE,
select(BANK_TRANSACTION.BANK_NAME,
BANK_TRANSACTION.TRANSFER_AMOUNT)
.from(BANK_TRANSACTION)
.where(BANK_TRANSACTION.CUSTOMER_NUMBER
.eq(PAYMENT.CUSTOMER_NUMBER)
.and(BANK_TRANSACTION.CHECK_NUMBER
.eq(PAYMENT.CHECK_NUMBER)))
.orderBy(BANK_TRANSACTION.TRANSFER_AMOUNT)
.forJSON().path().asField("transactions")).from(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))
.orderBy(PAYMENT.CACHING_DATE)
.forJSON().path().asField("payments"),
select(CUSTOMERDETAIL.CITY,
CUSTOMERDETAIL.ADDRESS_LINE_FIRST,CUSTOMERDETAIL.STATE)
.from(CUSTOMERDETAIL)
.where(CUSTOMERDETAIL.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))
.forJSON().path().asField("details")).from(CUSTOMER)
.orderBy(CUSTOMER.CREDIT_LIMIT).forJSON().path().fetch();
当然,您可以在与 模型 1 和 模型 3 的示例并排的捆绑代码中查看这些示例。
重要提示
除了 SQL/JSON 支持,jOOQ 还提供了 SQL/JSONB 支持。您可以通过 org.jooq.JSONB 和如 jsonbObject()、jsonbArrayAgg() 和 jsonbTable() 等运算符显式使用 JSONB。
现在,是时候讨论 SQL/XML 支持了。
处理 SQL/XML 支持
简而言之,对于 SQL/XML,我们可以讨论以下在 org.jooq.impl.DSL 类中有 jOOQ 实现的运算符:
-
XMLELEMENT(DSL.xmlelement()),XMLATTRIBUTES(DSL.xmlattributes()),XMLFOREST(DSL.xmlforest()),XMLCONCAT(DSL.xmlconcat()), 和XMLCOMMENT(DSL.xmlcomment()) 用于构建 XML 数据 -
XMLAGG(DSL.xmlagg()) 用于将数据聚合到嵌套的 XML 文档中 -
XMLEXISTS(DSL.xmlexists()) 和XMLQUERY(DSL.xmlquery()) 用于使用XPath查询 XML 文档 -
XMLPARSE(DSL.xmlparseContent()和DSL.xmlparseDocument()) 用于解析 XML 内容和文档 -
XMLPI(DSL.xmlpi()) 用于生成 XML 处理指令 -
XMLTABLE(DSL.xmltable()) 用于将 XML 值转换为 SQL 表
SQL Server 的 FOR XML 语法(包括 ROOT、PATH、ELEMENTS、RAW、AUTO 和 EXPLICIT (jOOQ 3.17.x +))—— jOOQ 的商业版本模拟了不支持 FOR XML 语法的数据库的语法(在这本书中,您可以为此 SQL Server 和 Oracle 练习)。
让我们通过 jOOQ DSL API 看一些这些运算符的入门示例。
从值构建和聚合 XML 数据
从值构建 XML 数据的良好起点依赖于 XMLELEMENT 运算符。在 jOOQ 中,XMLELEMENT 通过 xmlelement() 方法呈现。例如,以下代码片段检索每个客户的 CUSTOMER_NAME 字段,并将其用作名为 <name/> 的 XML 元素的文本:
Result<Record1<XML>> result = ctx.select(
xmlelement("name", CUSTOMER.CUSTOMER_NAME))
.from(CUSTOMER).fetch();
返回的 Result 的大小等于检索到的客户数量。每个 Record1 包裹一个代表 <name/> 元素的 org.jooq.XML 实例。如果我们只想将此 Result 格式化为 XML,我们可以调用 formatXML() 方法(这将在下一章中介绍)。这将返回一个简单的格式化表示,如下所示:
<result xmlns="http:…">
<fields>
<field name="xmlconcat" type="XML"/>
</fields>
<records>
<record xmlns="http:…">
<value field="xmlconcat">
<name>Australian Home</name>
</value>
</record>
<record xmlns="http:…">
<value field="xmlconcat">
<name>Joliyon</name>
</value>
</record>
…
然而,这个响应太冗长了,不适合发送到客户端。例如,您可能只需要 "records" 元素。在这种情况下,我们可以依靠 formatXML(XMLFormat) 风味,正如您将在捆绑代码中看到的那样。假设您只想发送第一个 <name/> 元素,您可以从 Result 对象中提取它作为 result.get(0).value1().data():
result.get(0) // 0-first <name/>, 1-second <name/> …
.value1() // this is the value from Record1, a XML
.data() // this is the data of the first XML as String
<name>Australian Home</name>
然而,也许您计划将这些 <name/> 标签作为 List<String> 发送到客户端。然后,依靠 fetchInto(String.class) 返回所有 <name/> 元素作为 List<String>。请注意,每个 String 都是一个 <name/>:
List<String> result = ctx.select(
xmlelement("name", CUSTOMER.CUSTOMER_NAME))
.from(CUSTOMER).fetchInto(String.class);
或者,将所有这些 <name/> 元素聚合为一个单一的 String 会更实用。这可以通过 xmlagg() 实现,它返回一个包含所有获取数据的单个 XML(为了方便,让我们将所有内容聚合在 <names/> 标签下):
String result = ctx.select(xmlelement("names", xmlagg(
xmlelement("name", CUSTOMER.CUSTOMER_NAME))))
.from(CUSTOMER).fetchSingleInto(String.class);
聚合的 XML 如此显示:
<names>
<name>Australian Home</name>
<name>Joliyon</name>
...
</names>
那么给 XML 元素添加属性怎么办?这可以通过 xmlattributes() 实现,如下面的直观示例所示:
Result<Record1<XML>> result = ctx.select(xmlelement("contact",
xmlattributes(CUSTOMER.CONTACT_FIRST_NAME.as("firstName"),
CUSTOMER.CONTACT_LAST_NAME.as("lastName"), CUSTOMER.PHONE)))
.from(CUSTOMER).fetch();
预期的 XML 将看起来像这样:
<contact firstName="Sart"
lastName="Paoule" phone="40.11.2555"/>
一个相对有用的 XML 操作符是 xmlforest()。这个操作符将其参数转换为 XML,并返回通过这些转换参数的连接获得的 XML 片段。以下是一个示例:
Result<Record1<XML>> result = ctx.select(
xmlelement("allContacts", xmlagg(xmlelement("contact",
xmlforest(CUSTOMER.CONTACT_FIRST_NAME.as("firstName"),
CUSTOMER.CONTACT_LAST_NAME.as("lastName"),
CUSTOMER.PHONE)))))
.from(CUSTOMER).fetch();
xmlforest() 的效果可以在生成的 XML 中看到:
<allContacts>
<contact>
<firstName>Sart</firstName>
<lastName>Paoule</lastName>
<phone>40.11.2555</phone>
</contact>
…
</allContacts>
如果你是一个 SQL Server 粉丝,那么你知道可以通过非标准的 FOR XML 语法来以 XML 格式获取数据。jOOQ 通过 forXml() API 支持这种语法。它还支持诸如 ROOT 通过 root(),PATH 通过 path(),AUTO 通过 auto(),RAW 通过 raw(),以及 ELEMENTS 通过 elements() 等子句,如下面的示例所示:
Result<Record1<XML>> result = ctx.select(
OFFICE.OFFICE_CODE, OFFICE.CITY, OFFICE.COUNTRY)
.from(OFFICE)
.forXML().path("office").elements().root("offices")
.fetch();
生成的 XML 看起来像这样:
<offices>
<office>
<office_code>1</office_code>
<city>San Francisco</city>
<country>USA</country>
</office>
<office>
<office_code>10</office_code>
</office>
<office>
<office_code>11</office_code>
<city>Paris</city>
<country>France</country>
</office>
...
</offices>
注意,缺失的标签(检查第二个 <office/> 实例,它没有 <city/> 或 <country/>)表示缺失的数据。
作为旁注,让我提一下,jOOQ 还可以通过在 Record1<XML> 上调用 intoXML() 的某个版本将 XML 转换为 org.w3c.dom.Document。此外,你还会喜欢 jOOX,或面向对象的 XML (github.com/jOOQ/jOOX),它可以用来进行 XSL 转换或以 jQuery 风格导航生成的 XML 文档。
我完全同意(分享他的热情),Lukas Eder 表示:
"我不知道你,但当我看到这些示例时,我只想写一个使用 jOOQ 的巨大应用程序 😃 我的意思是,除了查询数据库并生成 JSON 或 XML 文档,还有其他什么人会有这样的需求??"
你可以在 SimpleXml 的捆绑代码中看到这些示例(以及许多其他示例),并熟悉生成的 XML。现在,让我们谈谈如何对生成的 XML 内容进行排序和限制。
使用 ORDER BY 和 LIMIT
当我们不使用 xmlagg() 聚合操作符时,排序和限制与常规查询相同。例如,你可以按 CUSTOMER_NAME 排序,并将结果限制为三个 XML,如下所示:
Result<Record1<XML>> result = ctx.select(
xmlelement("name", CUSTOMER.CUSTOMER_NAME))
.from(CUSTOMER)
.orderBy(CUSTOMER.CUSTOMER_NAME).limit(3).fetch();
另一方面,当使用 xmlagg() 聚合操作符时,限制应该在聚合之前进行(例如,在子查询、JOIN 等)。否则,此操作将应用于生成的聚合本身。在聚合过程中,排序可以在限制之前进行。例如,在以下示例中,子查询按 CONTACT_LAST_NAME 排序客户,并将返回的结果限制为 3,而聚合按 CONTACT_FIRST_NAME 排序这个结果:
String result = ctx.select(xmlelement("allContacts",
xmlagg(xmlelement("contact",
xmlforest(field("contact_first_name").as("firstName"),
field("contact_last_name").as("lastName"),field("phone"))))
.orderBy(field("contact_first_name"))))
.from(select(CUSTOMER.CONTACT_FIRST_NAME,
CUSTOMER.CONTACT_LAST_NAME, CUSTOMER.PHONE)
.from(CUSTOMER).orderBy(CUSTOMER.CONTACT_LAST_NAME).limit(3))
.fetchSingleInto(String.class);
结果聚合按 CUSTOMER_FIRST_NAME 排序:
<allContacts>
<contact>
<firstName>Mel</firstName>
<lastName>Andersen</lastName>
<phone>030-0074555</phone>
</contact>
<contact>
<firstName>Paolo</firstName>
<lastName>Accorti</lastName>
<phone>011-4988555</phone>
</contact>
<contact>
<firstName>Raanan</firstName>
<lastName>Altagar,G M</lastName>
<phone>+ 972 9 959 8555</phone>
</contact>
</allContacts>
更多关于 SimpleXml 的示例可以在捆绑代码中找到。现在,让我们学习如何使用 XPath 查询 XML 文档。
使用 XPath 查询 XML 文档
可以通过 XPath 表达式来查询 XML 文档,我们可以区分通过 XMLEXISTS (xmlexists()) 检查元素/属性存在性的查询和通过 XMLQUERY (xmlquery()) 从 XML 文档中获取特定数据的查询。例如,在 PRODUCTLINE 中,有一个名为 HTML_DESCRIPTION 的字段,它以 XML 格式保存产品线的描述。如果一个产品线有描述,那么这个描述以 <productline/> 标签开始。因此,可以通过 xmlexists() 获取所有有描述的产品线,如下所示:
Result<Record1<String>> result =
ctx.select(PRODUCTLINE.PRODUCT_LINE)
.from(PRODUCTLINE)
.where(xmlexists("/productline")
.passing(PRODUCTLINE.HTML_DESCRIPTION)).fetch();
在 xmlexists("/productline").passing(…) 中,/productline 代表要搜索的 XPath,而 passing() 方法的参数代表搜索此 XPath 的 XML 文档(或片段)。
另一方面,以下代码片段依赖于 xmlquery() 从 HTML_DESCRIPTION 中获取包含某些数据的 XML:
String result = ctx.select(xmlagg(
xmlquery("productline/capacity/c[position()=last()]")
.passing(PRODUCTLINE.HTML_DESCRIPTION)))
.from(PRODUCTLINE).fetchSingleInto(String.class);
当然,passing() 的参数也可以是从某些字段构建的 XML:
Result<Record1<XML>> result = ctx.select(
xmlquery("//contact/phone").passing(
xmlelement("allContacts", xmlagg(xmlelement("contact",
xmlforest(CUSTOMER.CONTACT_FIRST_NAME.as("firstName"),
CUSTOMER.CONTACT_LAST_NAME.as("lastName"),
CUSTOMER.PHONE))))))
.from(CUSTOMER).fetch();
这个查询从给定的 XML 中获取所有 <phone/> 标签(例如,<phone>(26) 642-7555</phone>)。更多示例可以在 SimpleXml 中找到。接下来,让我们处理 XMLTABLE。
将 XML 值转换为 SQL 表
将 XML 值转换为 SQL 表可以通过 XMLTABLE 操作符来完成,在 jOOQ 中,它等同于 xmltable()。例如,让我们构建一个包含从 HTML_DESCRIPTION 中提取的每个产品线详细信息的 SQL 表:
Result<Record> result = ctx.select(table("t").asterisk())
.from(PRODUCTLINE, xmltable("//productline/details")
.passing(PRODUCTLINE.HTML_DESCRIPTION)
.column("id").forOrdinality()
.column("power", VARCHAR)
.column("type", VARCHAR)
.column("nr_of_lines", INTEGER).path("type/@nr_of_lines")
.column("command", VARCHAR).path("type/@command")
.as("t")).fetch();
这个查询将生成以下表格:

图 8.4 – 上一个查询的结果
一旦获取了 SQL 表,你就可以像处理任何其他数据库表一样思考和操作它。为了简洁,我简单地使用了 VARCHAR,但最好指定一个大小,以避免默认为 VARCHAR(max)。
更多示例,包括如何使用 XMLTABLE 与聚合、ORDER BY、LIMIT 以及如何将 SQL 表转换回 XML,可以在 SimpleXml 中找到。
通过 SQL/XML 处理关系
通过 jOOQ SQL/XML 支持处理典型的 1:1、1:n 和 n:n 关系。让我们快速了解一下。
将关系映射到 XML
大多数情况下,这些关系可以通过 xmlelement()、xmlagg() 和 xmlforest() 的深思熟虑的组合来实体化为 XML。由于你已经熟悉 PRODUCTLINE 和 PRODUCT 之间的一对多关系,让我们通过 SQL/XML 支持将其塑造成 XML:
Result<Record1<XML>> result = ctx.select(
xmlelement("productLine",
xmlelement("productLine", PRODUCTLINE.PRODUCT_LINE),
xmlelement("textDescription", PRODUCTLINE.TEXT_DESCRIPTION),
xmlelement("products", field(select(xmlagg(
xmlelement("product", xmlforest(
PRODUCT.PRODUCT_NAME.as("productName"),
PRODUCT.PRODUCT_VENDOR.as("productVendor"),
PRODUCT.QUANTITY_IN_STOCK.as("quantityInStock")))))
.from(PRODUCT)
.where(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE))))))
.from(PRODUCTLINE)
.orderBy(PRODUCTLINE.PRODUCT_LINE).fetch();
从前面的代码中,你可以推断出表达一对一和多对多关系只是通过 SQL/XML 操作符进行操作的问题。你可以在 XmlRelationships 的捆绑代码中找到这些示例,包括如何使用 JOIN 而不是 SELECT 子查询。
如果你认为 Result<Record1<XML>> 还没有准备好发送给客户端(例如,通过 REST 控制器),那么可以通过将所有产品行聚合到一个 XML 元素(根元素)下并依赖 fetchSingleInto() 来稍微装饰一下,如下所示:
String result = ctx.select(
xmlelement("productlines", xmlagg(
xmlelement("productLine",
...
.from(PRODUCTLINE).fetchSingleInto(String.class);
在 SQL Server 中,我们可以通过 forXml() 获取类似的结果:
Result<Record1<XML>> result = ctx.select(
PRODUCTLINE.PRODUCT_LINE.as("productLine"),
PRODUCTLINE.TEXT_DESCRIPTION.as("textDescription"),
select(PRODUCT.PRODUCT_NAME.as("productName"),
PRODUCT.PRODUCT_VENDOR.as("productVendor"),
PRODUCT.QUANTITY_IN_STOCK.as("quantityInStock"))
.from(PRODUCT)
.where(PRODUCT.PRODUCT_LINE.eq(PRODUCTLINE.PRODUCT_LINE))
.forXML().path().asField("products"))
.from(PRODUCTLINE)
.forXML().path("productline").root("productlines")
.fetch();
或者,我们可以通过 formatXML(XMLformat) 获取一个 String:
String result = ctx.select(
PRODUCTLINE.PRODUCT_LINE.as("productLine"),
...
.forXML().path("productline").root("productlines")
.fetch()
.formatXML(XMLFormat.DEFAULT_FOR_RECORDS);
这两个示例将产生几乎相同的 XML,如下所示(正如你所见,可以通过别名 as("alias") 来更改从字段名推断出的默认 XML 标签):
<productlines>
<productline>
<productLine>Classic Cars</productLine>
<textDescription>Attention ...</textDescription>
<products>
<product>
<productName>1952 Alpine Renault 1300</productName>
<productVendor>Classic Metal Creations</productVendor>
<quantityInStock>7305</quantityInStock>
</product>
<product>
<productName>1972 Alfa Romeo GTA</productName>
<productVendor>Motor City Art Classics</productVendor>
<quantityInStock>3252</quantityInStock>
</product>
...
</products>
</productline>
<productline>
<productLine>Motorcycles</productLine>
...
你可以在 XmlRelationships 应用程序中查看这些示例。
映射任意嵌套模型
jOOQ 允许我们通过 SQL/XML 支持,将任意嵌套的模型映射,而不仅仅是众所周知的 1:1、1:n 和 n:n 关系。还记得 模型 2(见 图 8.3)吗?好吧,你已经知道如何通过 SQL/JSON 支持来获取和映射该模型,所以这次,让我们看看它是如何通过 SQL/XML 来实现的:
Result<Record1<XML>> result = ctx.select(
xmlelement("customer",
xmlelement("customerName", CUSTOMER.CUSTOMER_NAME),
xmlelement("creditLimit", CUSTOMER.CREDIT_LIMIT),
xmlelement("payments", field(select(xmlagg(
xmlelement("payment", // optional
xmlforest(PAYMENT.CUSTOMER_NUMBER.as("customerNumber"),
PAYMENT.INVOICE_AMOUNT.as("invoiceAmount"),
PAYMENT.CACHING_DATE.as("cachingDate"),
field(select(xmlagg(xmlelement("transaction", // optional
xmlforest(BANK_TRANSACTION.BANK_NAME.as("bankName"),
BANK_TRANSACTION.TRANSFER_AMOUNT.as("transferAmount")))))
.from(BANK_TRANSACTION)
.where(BANK_TRANSACTION.CUSTOMER_NUMBER
.eq(PAYMENT.CUSTOMER_NUMBER)
.and(BANK_TRANSACTION.CHECK_NUMBER
.eq(PAYMENT.CHECK_NUMBER)))).as("transactions")))))
.from(PAYMENT).where(PAYMENT.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER)))),
xmlelement("details", field(select(xmlagg(
xmlforest(CUSTOMERDETAIL.ADDRESS_LINE_FIRST
.as("addressLineFirst"),
CUSTOMERDETAIL.STATE.as("state"))))
.from(CUSTOMERDETAIL)
.where(CUSTOMERDETAIL.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))))))
.from(CUSTOMER).orderBy(CUSTOMER.CREDIT_LIMIT).fetch();
这就是示例的力量;没有太多其他可说的。花点时间剖析这个查询,并查看捆绑的代码以查看输出。此外,在捆绑的代码中,你还可以看到 模型 1 和 模型 3。应用程序名为 NestedXml。
作为 SQL Server 的粉丝,你可能对通过 forXML() 表达的先前查询更感兴趣,所以这里就是:
Result<Record1<XML>> result = ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT,
select(PAYMENT.CUSTOMER_NUMBER, PAYMENT.INVOICE_AMOUNT,
PAYMENT.CACHING_DATE, select(BANK_TRANSACTION.BANK_NAME,
BANK_TRANSACTION.TRANSFER_AMOUNT).from(BANK_TRANSACTION)
.where(BANK_TRANSACTION.CUSTOMER_NUMBER
.eq(PAYMENT.CUSTOMER_NUMBER)
.and(BANK_TRANSACTION.CHECK_NUMBER
.eq(PAYMENT.CHECK_NUMBER)))
.orderBy(BANK_TRANSACTION.TRANSFER_AMOUNT)
.forXML().path().asField("transactions")).from(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))
.orderBy(PAYMENT.CACHING_DATE)
.forXML().path().asField("payments"),
select(CUSTOMERDETAIL.CITY,
CUSTOMERDETAIL.ADDRESS_LINE_FIRST, CUSTOMERDETAIL.STATE)
.from(CUSTOMERDETAIL)
.where(CUSTOMERDETAIL.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))
.forXML().path().asField("details"))
.from(CUSTOMER).orderBy(CUSTOMER.CREDIT_LIMIT)
.forXML().path().fetch();
在捆绑的代码 NestedXml 中,你可以练习更多示例,由于篇幅原因,这里没有列出。记住,特别是对于这一章,我已经敲响了鼓。现在,是时候引入一个完整的乐团,向 jOOQ 映射的最酷特性致敬了。女士们,先生们,请允许我介绍 MULTISET!
通过惊人的 MULTISET 实现嵌套集合
MULTISET 值构造器(或简称 MULTISET)是 SQL 标准的未来,它将嵌套子查询(除了标量子查询)塑造成单个嵌套集合值。jOOQ 3.15+ 提供了令人惊叹和辉煌的 MULTISET 支持。它之所以令人惊叹,是因为尽管它拥有巨大的力量,但通过 jOOQ 使用它却非常简单(轻松)且直观;它之所以辉煌,是因为它可以以完全类型安全的方式,无需反射、无 N+1 风险、无重复地产生 jOOQ Record 或 DTO(POJO/Java 记录)的任何嵌套集合值。这允许数据库执行嵌套并优化查询执行计划。
考虑一下PRODUCTLINE和PRODUCT之间众所周知的一对多关系。我们可以通过 jOOQ 的<R extends Record> Field<Result<R>> multiset(Select<R> select)在 jOOQ 3.17.x 之前获取和映射这个关系,以及从 jOOQ 3.17.x 开始的Field<Result<R>> multiset(TableLike<R> table),如下所示(稍后我们将把这个示例称为展示 A):
var result = ctx.select(
PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.TEXT_DESCRIPTION,
multiset(
select(PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR,
PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCT)
.where(PRODUCTLINE.PRODUCT_LINE
.eq(PRODUCT.PRODUCT_LINE))
).as("products")) // MULTISET ends here
.from(PRODUCTLINE)
.orderBy(PRODUCTLINE.PRODUCT_LINE)
.fetch();
因此,使用方法非常简单!jOOQ 的multiset()构造函数接受一个SELECT语句作为参数(或者,从 jOOQ 3.17.x 开始,一个类似表的对象)。从形式上来说,这个SELECT语句的结果集代表一个将在外部集合(由外部SELECT语句产生的结果集)中嵌套的集合。通过嵌套/混合multiset()和select()(或selectDistinct()),我们可以实现任何级别或形状/层次结构的嵌套集合。之前,我们使用了 Java 10 的var关键字作为结果类型的类型,但实际类型是Result<Record3<String, String, Result<Record3<String, String, Integer>>>>。当然,更多的嵌套会产生一个真正难以理解的Result对象,所以使用var是推荐的做法。如您所直觉到的,Result<Record3<String, String, Integer>>是由multiset()中的SELECT语句产生的,而Result<Record3<String, String, nested_result>>是由外部SELECT语句产生的。以下图表将帮助您更好地理解这个类型:


图 8.5 – 上一个查询返回的类型
由于数据库中 MULTISET 的原生支持相当差,jOOQ 必须通过 SQL/JSON 或 SQL/XML 运算符来模拟它。例如,前面的查询在 MySQL 中渲染出以下 SQL(查看 jOOQ 如何使用json_merge_preserve()和json_array()):
SET @t = @@group_concat_max_len;
SET @@group_concat_max_len = 4294967295;
SELECT `classicmodels`.`productline`.`product_line`,
`classicmodels`.`productline`.`text_description`,
(SELECT coalesce(json_merge_preserve('[]', concat('[',
group_concat(json_array(`v0`, `v1`, `v2`) separator
','), ']')), json_array())
FROM
(SELECT `classicmodels`.`product`.`product_name` AS `v0`,
`classicmodels`.`product`.`product_vendor` AS `v1`,
`classicmodels`.`product`.`quantity_in_stock` AS `v2`
FROM `classicmodels`.`product`
WHERE `classicmodels`.`productline`.`product_line` =
`classicmodels`.`product`.`product_line`)
AS `t`) AS `products`
FROM `classicmodels`.`productline`
ORDER BY `classicmodels`.`productline`.`product_line`;
SET @@group_concat_max_len = @t;
在任何时刻,您都可以通过formatJSON()/formatXML()将这个Record集合转换成普通的 JSON 或 XML 格式。然而,请允许我借此机会强调,如果您只想获取 JSON/XML(因为这是您的客户端需要的),那么直接使用 SQL/JSON 和 SQL/XML 运算符(如前文所述)比通过 MULTISET 传递更好。您可以在捆绑的代码中找到示例,例如在MultisetRelationships中,以及如何使用 MULTISET 进行一对一和多对多关系的示例。在多对多关系的示例中,您可以看到 jOOQ 类型安全的隐式(一对一)连接功能与 MULTISET 配合得多么好。
记得模型 2(见图 8.3)吗?好吧,您已经知道如何通过 SQL/JSON 和 SQL/XML 支持获取和映射该模型,那么让我们看看如何通过 MULTISET 来做这件事(稍后我们将把这个示例称为展示 B):
var result = ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT,
multiset(select(PAYMENT.CUSTOMER_NUMBER,
PAYMENT.INVOICE_AMOUNT, PAYMENT.CACHING_DATE,
multiset(select(BANK_TRANSACTION.BANK_NAME,
BANK_TRANSACTION.TRANSFER_AMOUNT)
.from(BANK_TRANSACTION)
.where(BANK_TRANSACTION.CUSTOMER_NUMBER
.eq(PAYMENT.CUSTOMER_NUMBER)
.and(BANK_TRANSACTION.CHECK_NUMBER
.eq(PAYMENT.CHECK_NUMBER)))
.orderBy(BANK_TRANSACTION.TRANSFER_AMOUNT)))
.from(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))).as("payments"),
multiset(select(CUSTOMERDETAIL.CITY,
CUSTOMERDETAIL.ADDRESS_LINE_FIRST,
CUSTOMERDETAIL.STATE)
.from(CUSTOMERDETAIL)
.where(CUSTOMERDETAIL.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER)))
.as("customer_details"))
.from(CUSTOMER)
.orderBy(CUSTOMER.CREDIT_LIMIT.desc())
.fetch();
这次,返回的类型相当冗长:Result<Record4<String, BigDecimal, Result<Record4<Long, BigDecimal, LocalDateTime, Result<Record2<String, BigDecimal>>>>, Result<Record3<String, String, String>>>>。以下图表解释了这一点:

图 8.6 – 上一个查询返回的类型
你可以在名为 NestedMultiset 的应用程序中找到这个例子,与模型 1 和模型 3 一起。接下来,让我们看看如何将 MULTISET 映射到 DTO(例如,POJO 和 Java 16 记录)。
将 MULTISET 映射到 DTO
将 MULTISET 的结果作为通用结构类型是很有趣的,但很可能是你更喜欢有一个 POJO/Java 记录的 List。例如,如果我们考虑 展示 A,那么你可能会编写以下 Java 记录作为映射模型:
public record RecordProduct(String productName,
String productVendor, Integer quantityInStock) {}
public record RecordProductLine(String productLine,
String textDescription, List<RecordProduct> products) {}
因此,你期望 Result<Record3<String, String, Integer>> 将通过 MULTISET 获取并映射到 List<RecordProduct>,整个查询结果到 List<RecordProductLine>。第一部分可以通过新引入的 ad hoc Field.convertFrom() 转换器完成,该转换器在 第七章 中介绍,类型、转换器和绑定。使用 Field.convertFrom(),我们将给定的 Field<T>(在这里,Field<Result<Record3<String, String, Integer>>> 由 multiset() 返回)转换为只读 Field<U>(在这里,Field<List<RecordProduct>>),用于 ad hoc 使用:
Field<List<RecordProduct>> result = multiset(
select(PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR,
PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCT)
.where(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE)))
.as("products").convertFrom(r ->
r.map(mapping(RecordProduct::new)));
r -> r.map(mapping(RecordProduct::new)) 中的 r 参数是 Result<Record3<String, String, Integer>>,因此这个 lambda 可以看作是 Result<Record3<String, String, Integer>> -> RecordProduct。r.map(…) 部分是 Result.map(RecordMapper<R, E>) 方法。最后,本章前面介绍的 Records.mapping() 方法将 Function3<String, String, Integer, RecordProduct> 类型的构造器引用转换为 RecordMapper 参数,该参数进一步用于将 Result<Record3<String, String, Integer>> 转换为 List<RecordProduct>。结果 Field<List<SimpleProduct>>(类似于任何其他 jOOQ Field)现在是外层 SELECT 中 PRODUCTLINE.PRODUCT_LINE(它是一个 String)和 PRODUCTLINE.TEXT_DESCRIPTION(它也是一个 String)的一部分。
因此,我们最后的任务是转换最外层的 Result3<String, String, List<RecordProduct>> 到 List<RecordProductLine>。为此,我们仅依赖于 mapping(),如下所示:
List<RecordProductLine> resultRecord = ctx.select(
PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.TEXT_DESCRIPTION,
multiset(
select(PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR,
PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCT)
.where(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE)))
.as("products").convertFrom(r ->
r.map(Records.mapping(RecordProduct::new))))
.from(PRODUCTLINE)
.orderBy(PRODUCTLINE.PRODUCT_LINE)
.fetch(mapping(RecordProductLine::new));
完成!现在,我们可以操作 List<RecordProductLine>。你可以在 MultisetRelationshipsInto 中找到这个例子。通过将我们在这里学到的知识应用到更复杂的 展示 B 中,我们得到以下模型:
public record RecordBank (
String bankName, BigDecimal transferAmount) {}
public record RecordCustomerDetail(
String city, String addressLineFirst, String state) {}
public record RecordPayment(
Long customerNumber, BigDecimal invoiceAmount,
LocalDateTime cachingDate, List<RecordBank> transactions) {}
public record RecordCustomer(String customerName,
BigDecimal creditLimit, List<RecordPayment> payments,
List<RecordCustomerDetail> details) {}
而 展示 B 查询如下:
List<RecordCustomer> resultRecord = ctx.select(
CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT,
multiset(select(PAYMENT.CUSTOMER_NUMBER,
PAYMENT.INVOICE_AMOUNT, PAYMENT.CACHING_DATE,
multiset(select(BANK_TRANSACTION.BANK_NAME,
BANK_TRANSACTION.TRANSFER_AMOUNT)
.from(BANK_TRANSACTION)
.where(BANK_TRANSACTION.CUSTOMER_NUMBER
.eq(PAYMENT.CUSTOMER_NUMBER)
.and(BANK_TRANSACTION.CHECK_NUMBER
.eq(PAYMENT.CHECK_NUMBER)))
.orderBy(BANK_TRANSACTION.TRANSFER_AMOUNT))
.convertFrom(r -> r.map(mapping(RecordBank::new))))
.from(PAYMENT)
.where(PAYMENT.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER))).as("payments")
.convertFrom(r -> r.map(mapping(RecordPayment::new))),
multiset(select(CUSTOMERDETAIL.CITY,
CUSTOMERDETAIL.ADDRESS_LINE_FIRST,
CUSTOMERDETAIL.STATE)
.from(CUSTOMERDETAIL)
.where(CUSTOMERDETAIL.CUSTOMER_NUMBER
.eq(CUSTOMER.CUSTOMER_NUMBER)))
.as("customer_details")
.convertFrom(r ->
r.map(mapping(RecordCustomerDetail::new))))
.from(CUSTOMER)
.orderBy(CUSTOMER.CREDIT_LIMIT.desc())
.fetch(mapping(RecordCustomer::new));
这个例子,与 图 8.3 中的模型 1 和模型 3 的例子一起,可以在 NestedMultiset 中找到。接下来,让我们解决 MULTISET_AGG() 函数。
MULTISET_AGG() 函数
jOOQ 的 MULTISET_AGG() 函数是一个合成聚合函数,可以用作 MULTISET 的替代品。其目标是以类型安全的方式将数据聚合到一个表示为 jOOQ Result 的嵌套集合中。当我们需要按某些聚合值排序或基于非深层嵌套集合的结果创建 WHERE 语句时,MULTISET_AGG() 函数是一个方便的解决方案。例如,众所周知的一对多 PRODUCTLINE:PRODUCT 关系可以如下聚合为一个嵌套集合(结果类型为 Result<Record3<String, String, Result<Record3<String, String, Integer>>>>):
从 jOOQ 3.17.x 版本开始,我们可以通过 DSL.asMultiset() 方法将类型为 String、Name、Field 等的表达式转换为多重集。查看 jOOQ 文档以获取更多详细信息。
var result = ctx.select(
PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.TEXT_DESCRIPTION,
multisetAgg(PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR,
PRODUCT.QUANTITY_IN_STOCK).as("products"))
.from(PRODUCTLINE)
.join(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE))
.groupBy(PRODUCTLINE.PRODUCT_LINE,
PRODUCTLINE.TEXT_DESCRIPTION)
.orderBy(PRODUCTLINE.PRODUCT_LINE)
.fetch();
此示例与其他更多示例一起在 MultisetAggRelationships 中提供。
将 Result 对象映射到 DTO(例如,POJO 和 Java 16 记录)是通过遵循与 MULTISET 相同的原则来完成的:
List<RecordProductLine> resultRecord = ctx.select(
PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.TEXT_DESCRIPTION,
multisetAgg(PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR,
PRODUCT.QUANTITY_IN_STOCK).as("products")
.convertFrom(r -> r.map(mapping(RecordProduct::new))))
.from(PRODUCTLINE)
.join(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE))
.groupBy(PRODUCTLINE.PRODUCT_LINE,
PRODUCTLINE.TEXT_DESCRIPTION)
.orderBy(PRODUCTLINE.PRODUCT_LINE)
.fetch(mapping(RecordProductLine::new));
此示例与其他示例一起在 MultisetAggRelationshipsInto 中提供。接下来,让我们尝试比较多重集。
比较多重集(MULTISETs)
多重集也可以用于谓词中。查看以下示例:
ctx.select(count().as("equal_count"))
.from(EMPLOYEE)
.where(multiset(selectDistinct(SALE.FISCAL_YEAR)
.from(SALE)
.where(EMPLOYEE.EMPLOYEE_NUMBER
.eq(SALE.EMPLOYEE_NUMBER)))
.eq(multiset(select(val(2003).union(select(val(2004))
.union(select(val(2007)))))))
.fetch();
但我们何时可以说两个多重集是相等的?查看以下旨在阐明此点的示例:
// A
ctx.selectCount()
.where(multiset(select(val("a"), val("b"), val("c")))
.eq(multiset(select(val("a"), val("b"), val("c")))))
.fetch();
// B
ctx.selectCount()
.where(multiset(select(val("a"), val("b"), val("c")))
.eq(multiset(select(val("a"), val("c"), val("b")))))
.fetch();
// C
ctx.selectCount()
.where(multiset(select(val("a")).union(select(val("b"))
.union(select(val("c")))))
.eq(multiset(select(val("a")).union(select(val("b"))
.union(select(val("c")))))))
.fetch();
// D
ctx.selectCount()
.where(multiset(select(val("a")).union(select(val("b"))
.union(select(val("c")))))
.eq(multiset(select(val("a")).union(select(val("b"))))))
.fetch();
那么,A、B、C 和 D 中哪一个会返回 1?正确答案是 A 和 C。这意味着如果两个多重集具有相同顺序的相同元素数量,则这两个多重集是相等的。该应用程序名为 MultisetComparing。请随意确定当多重集 X 大于/小于/包含…多重集 Y 的时间。
此外,别忘了阅读 blog.jooq.org/jooq-3-15s-new-multiset-operator-will-change-how-you-think-about-sql/ 和 blog.jooq.org/the-performance-of-various-to-many-nesting-algorithms/。看起来 jOOQ 3.17 将通过更多酷炫的功能丰富 MULTISET 支持,twitter.com/JavaOOQ/status/1493261571103105030,所以请保持关注!
此外,由于 MULTISET 和 MULTISET_AGG() 是如此热门的话题,你应该从 stackoverflow.com/search?q=%5Bjooq%5D+multiset 中公开的实际场景不断更新你的技能。
接下来,让我们谈谈延迟加载。
延迟加载
Hibernate JPA 人员:那么,你是如何处理 jOOQ 中的大量结果集的?
jOOQ 人员(我):jOOQ 支持延迟加载。
Hibernate JPA 人员:那么你是如何管理 LazyInitializationException 的?
jOOQ 的人(就是我):对于刚刚加入 Hibernate JPA 的新用户,我想从一开始就强调这一点——不要假设 jOOQ 懒加载与 Hibernate JPA 懒加载相关或类似。jOOQ 没有也不需要 Persistence Context,也不依赖于 Session 对象和代理对象。你的代码不会受到任何类型的懒加载异常的影响!
那么,jOOQ 懒加载是什么?
好吧,大多数情况下,将整个结果集加载到内存中是充分利用 RDBMS 的最佳方式(尤其是在通过优化小结果集和短事务来应对高流量的 Web 应用程序中)。然而,在某些情况下(例如,你可能有一个巨大的结果集),你可能希望分小块(例如,逐个)获取和处理结果集。对于此类场景,jOOQ 提供了 org.jooq.Cursor API。实际上,jOOQ 持有一个打开的结果集的引用,并允许你通过 fetchNext()、fetchNextOptional()、fetchNextInto() 和 fetchNextOptionalInto() 等多种方法迭代(即加载并处理到内存中)结果集。然而,要获取一个打开的结果集的引用,我们必须调用 fetchLazy() 方法,如下面的示例所示:
try (Cursor<CustomerRecord> cursor
= ctx.selectFrom(CUSTOMER).fetchLazy()) {
while (cursor.hasNext()) {
CustomerRecord customer = cursor.fetchNext();
System.out.println("Customer:\n" + customer);
}
}
注意,我们依赖于 try-with-resources 包装来确保在迭代过程结束时关闭底层的结果集。在这个代码片段中,jOOQ 通过 fetchNext() 方法逐个将记录从底层结果集提取到内存中,但这并不意味着 JDBC 驱动程序会做同样的事情。不同的数据库供应商以及同一数据库的不同版本之间,JDBC 驱动程序的行为可能不同。例如,MySQL 和 PostgreSQL 在单个数据库往返中预取所有记录,SQL Server 使用自适应缓冲(在 JDBC URL 中,我们有 selectMethod = direct; responseBuffering = adaptive;)和默认的 fetch size 为 128 以避免内存溢出错误,而 Oracle JDBC 从数据库游标中每次获取 10 行结果集(在 JDBC URL 级别,这可以通过 defaultRowPrefetch 属性来更改)。
重要提示
请记住,fetch size 只是一个 JDBC 提示,旨在指导驱动程序从数据库一次性获取的行数。然而,JDBC 驱动程序可以自由忽略这个提示。
在 jOOQ 中,可以通过 ResultQuery.fetchSize(int size) 或 Settings.withFetchSize(int size) 来配置 fetch size。jOOQ 使用此配置来设置底层的 Statement.setFetchSize(int size) JDBC。大多数 JDBC 驱动程序仅在特定上下文中应用此设置。例如,MySQL 应该只在以下情况下应用此设置:
-
设置单向结果集(这可以通过 jOOQ 的
resultSetType()来设置)。 -
设置并发只读结果集(通过 jOOQ 的
resultSetConcurrency())。
获取大小设置为Integer.MIN_VALUE以逐条获取记录,或者设置为所需的大小,同时在 JDBC URL 中添加useCursorFetch=true以依赖基于游标的流式传输。
这里是一个利用这些设置的 MySQL 代码片段:
try (Cursor<CustomerRecord> cursor = ctx.selectFrom(CUSTOMER)
.resultSetType(ResultSet.TYPE_FORWARD_ONLY)
.resultSetConcurrency(ResultSet.CONCUR_READ_ONLY)
.fetchSize(Integer.MIN_VALUE).fetchLazy()) {
while (cursor.hasNext()) {
CustomerRecord customer = cursor.fetchNext();
System.out.println("Customer:\n" + customer);
}
}
完整的示例命名为LazyFetching。
另一方面,如果我们这样做,PostgreSQL 将使用 fetch size:
-
设置单向结果集(可以通过 jOOQ 的
resultSetType()设置) -
禁用自动提交模式(在 Spring Boot 中使用默认的 Hikari CP 连接池,这可以通过
application.properties中的以下标志属性完成,spring.datasource.hikari.auto-commit=false,或者在 jOOQ 中通过配置中的<jdbc/>标签的<autoCommit>false</autoCommit>实现)
因此,PostgreSQL 的代码可以如下所示:
try ( Cursor<CustomerRecord> cursor = ctx.selectFrom(CUSTOMER)
.resultSetType(ResultSet.TYPE_FORWARD_ONLY) // default
.fetchSize(1).fetchLazy()) {
while (cursor.hasNext()) {
CustomerRecord customer = cursor.fetchNext();
System.out.println("Customer:\n" + customer);
}
}
此外,在 PostgreSQL 中,可以通过defaultRowFetchSize更改 fetch size 并将其添加到 JDBC URL。完整的示例也命名为LazyFetching。
对于 SQL Server 和 Oracle,我们可以依赖默认的 fetch size,因为它们都防止内存溢出错误。然而,在使用 Microsoft JDBC 驱动程序(如本书中所述)时,在 SQL Server 中启用 fetch size 相当具有挑战性。如果你依赖 jTDS 驱动程序,这会简单得多。
我们为 SQL Server 和 Oracle 的示例(LazyFetching)依赖于默认的获取大小;因此,SQL Server 为 128,Oracle 为 10。
最后,你可以如下组合ResultSet和 Cursor API:
ResultSet rs = ctx.selectFrom(CUSTOMER)
.fetchLazy().resultSet();
Cursor<Record> cursor = ctx.fetchLazy(rs);
此外,你可以这样做:
Cursor<Record> result = ctx.fetchLazy(
rs, CUSTOMER.CUSTOMER_NAME, CUSTOMER.CREDIT_LIMIT);
Cursor<Record> result = ctx.fetchLazy(
rs, VARCHAR, DECIMAL);
Cursor<Record> result = ctx.fetchLazy(
rs, String.class, BigDecimal.class);
接下来,让我们谈谈通过流式传输的延迟获取。
通过 fetchStream()/fetchStreamInto()进行延迟获取
在 jOOQ 中,延迟获取也可以通过fetchStream()/fetchStreamInto()实现。此方法在内部保持一个打开的 JDBC 结果集,并允许我们流式传输其内容(即延迟将结果集加载到内存中)。例如,纯 SQL 可以利用DSLContext.fetchStream(),如下所示:
try ( Stream<Record> stream
= ctx.fetchStream("SELECT sale FROM sale")) {
stream.filter(rs -> rs.getValue("sale", Double.class) > 5000)
.forEach(System.out::println);
}
或者我们可以使用生成的基于 Java 的架构,如下所示:
try ( Stream<SaleRecord> stream
= ctx.selectFrom(SALE).fetchStream()) {
stream.filter(rs -> rs.getValue(SALE.SALE_) > 5000)
.forEach(System.out::println);
}
此代码与下一个使用stream()而不是fetchStream()的代码以相同的方式工作:
try ( Stream<SaleRecord> stream
= ctx.selectFrom(SALE).stream()) {
stream.filter(rs -> rs.getValue(SALE.SALE_) > 5000)
.forEach(System.out::println);
}
然而,请注意,此代码与下一个代码不同(前一个示例使用org.jooq.ResultQuery.stream(),而下一个示例使用java.util.Collection.stream()):
ctx.selectFrom(SALE)
.fetch() // jOOQ fetches the whole result set into memory
// and closes the database connection
.stream() // stream over the in-memory result set
// (no database connection is active)
.filter(rs -> rs.getValue(SALE.SALE_) > 5000)
.forEach(System.out::println);
在这里,fetch()方法将整个结果集加载到内存中并关闭数据库连接——这次,我们不需要 try-with-resources 包装,因为我们本质上是在流式传输记录列表。接下来,stream()方法在内存结果集上打开一个流,并且不会保持数据库连接打开。因此,请注意如何编写这样的代码片段,因为你很容易犯错误——例如,你可能需要延迟获取,但意外地添加了fetch(),或者你可能想要立即获取,但意外地忘记了添加fetch()。
使用 org.jooq.ResultQuery.collect()
有时候,我们需要流管道来应用特定的操作(例如,filter()),并收集结果,如下面的例子所示:
try ( Stream<Record1<Double>> stream = ctx.select(SALE.SALE_)
.from(SALE).fetchStream()) { // jOOQ API ends here
SimpleSale result = stream.filter( // Stream API starts here
rs -> rs.getValue(SALE.SALE_) > 5000)
.collect(Collectors.teeing(
summingDouble(rs -> rs.getValue(SALE.SALE_)),
mapping(rs -> rs.getValue(SALE.SALE_), toList()),
SimpleSale::new));
}
然而,如果我们实际上不需要流管道(例如,我们不需要filter()调用或任何其他操作),而我们只想懒加载地收集结果集,那么调用fetchStream()是没有意义的。但如果我们移除了fetchStream(),我们如何还能以懒加载的方式收集呢?答案是 jOOQ 的collect()方法,它在org.jooq.ResultQuery中可用。这个方法非常方便,因为它可以内部处理资源并绕过中间的Result数据结构。正如你所看到的,在移除fetchStream()调用后,没有必要使用 try-with-resources:
SimpleSale result = ctx.select(SALE.SALE_).from(SALE)
.collect(Collectors.teeing( // org.jooq.ResultQuery.collect()
summingDouble(rs -> rs.getValue(SALE.SALE_)),
mapping(rs -> rs.getValue(SALE.SALE_), toList()),
SimpleSale::new));
然而,请注意以下注意事项。
重要提示
确保流操作确实需要,总是一个好的实践。如果你的流操作有 SQL 对应项(例如,filter()可以用WHERE子句替换,summingDouble()可以用 SQL 的SUM()聚合函数替换),那么就使用 SQL。这将由于显著降低的数据传输而更快。所以,总是问问自己:“我能将这个流操作转换成我的 SQL 吗?”如果可以,那么就去做!如果不能,那么就使用流,就像我们在下面的例子中所做的那样。
这里还有一个例子,展示了懒加载地获取分组。由于collect(),jOOQ 不会在内存中获取所有内容,因为我们还设置了获取大小,JDBC 驱动程序会以小块(这里,一个块有五条记录)的形式获取结果集。PostgreSQL 版本如下:
Map<Productline, List<Product>> result = ctx.select()
.from(PRODUCTLINE).leftOuterJoin(PRODUCT)
.on(PRODUCTLINE.PRODUCT_LINE.eq(PRODUCT.PRODUCT_LINE))
.fetchSize(5) // Set the fetch size for JDBC driver
.collect(Collectors.groupingBy(
rs -> rs.into(Productline.class),
Collectors.mapping(
rs -> rs.into(Product.class), toList())));
完整的应用程序名为LazyFetchingWithStreams。接下来,让我们谈谈异步获取。
异步获取
无论何时你考虑需要异步获取(例如,一个查询需要太长时间等待或者多个查询可以独立运行(非原子性)),你都可以依赖 jOOQ + CompletableFuture的组合。例如,以下异步操作使用CompletableFuture API 和从默认的ForkJoinPool API 获取的线程链式连接了一个INSERT语句、一个UPDATE语句和一个DELETE语句(如果你不熟悉这个 API,那么你可以考虑购买 Packt 出版的Java Coding Problems这本书,它深入探讨了这一主题):
@Async
public CompletableFuture<Void> insertUpdateDeleteOrder() {
return CompletableFuture.supplyAsync(() -> {
return ctx.insertInto(ORDER)
.values(null, LocalDate.of(2003, 2, 12),
LocalDate.of(2003, 3, 1), LocalDate.of(2003, 2, 27),
"Shipped", "New order inserted...", 363L, BigDecimal.ZERO)
.returning().fetchOne();
}).thenApply(order -> {
order.setStatus("ON HOLD");
order.setComments("Reverted to on hold ...");
ctx.executeUpdate(order);
return order.getOrderId();
}).thenAccept(id -> ctx.deleteFrom(ORDER)
.where(ORDER.ORDER_ID.eq(id)).execute());
}
这个例子在名为SimpleAsync的应用程序旁边,可供 MySQL 使用。
你可以利用 CompletableFuture 和 jOOQ,如前一个示例所示。然而,你也可以依赖两个 jOOQ 快捷方式,fetchAsync() 和 executeAsync()。例如,假设我们想要获取经理(MANAGER)、办公室(OFFICE)和员工(EMPLOYEE),并以 HTML 格式将它们提供给客户端。由于这三个查询之间没有相互依赖,因此获取经理、办公室和员工可以异步进行。在这种情况下,jOOQ 的 fetchAsync() 方法允许我们编写以下三个方法:
@Async
public CompletableFuture<String> fetchManagersAsync() {
return ctx.select(MANAGER.MANAGER_ID, MANAGER.MANAGER_NAME)
.from(MANAGER).fetchAsync()
.thenApply(rs -> rs.formatHTML()).toCompletableFuture();
}
@Async
public CompletableFuture<String> fetchOfficesAsync() {
return ctx.selectFrom(OFFICE).fetchAsync()
.thenApply(rs -> rs.formatHTML()).toCompletableFuture();
}
@Async
public CompletableFuture<String> fetchEmployeesAsync() {
return ctx.select(EMPLOYEE.OFFICE_CODE,
EMPLOYEE.JOB_TITLE, EMPLOYEE.SALARY)
.from(EMPLOYEE).fetchAsync()
.thenApply(rs -> rs.formatHTML()).toCompletableFuture();
}
接下来,我们通过 CompletableFuture.allOf() 方法等待这三个异步方法完成:
public String fetchCompanyAsync() {
CompletableFuture<String>[] fetchedCf
= new CompletableFuture[]{
classicModelsRepository.fetchManagersAsync(),
classicModelsRepository.fetchOfficesAsync(),
classicModelsRepository.fetchEmployeesAsync()};
// Wait until they are all done
CompletableFuture<Void> allFetchedCf
= CompletableFuture.allOf(fetchedCf);
allFetchedCf.join();
// collect the final result
return allFetchedCf.thenApply(r -> {
StringBuilder result = new StringBuilder();
for (CompletableFuture<String> cf : fetchedCf) {
result.append(cf.join());
}
return result.toString();
}).join();
}
该方法返回的 String(例如,来自 REST 控制器)代表 jOOQ 通过 formatHTML() 方法生成的 HTML 片段。好奇这个 HTML 的样子吗?那么,只需在 MySQL 下运行 FetchAsync 应用程序,并使用提供的控制器在浏览器中获取数据。你可能还喜欢练习 ExecuteAsync(适用于 MySQL)应用程序,该应用程序使用 jOOQ 的 executeAsync() 方法作为示例。
Lukas Eder 提到:
"也许值得提一下,有一个 ExecutorProvider SPI 允许在默认的 ForkJoinPool 不是正确的地方时将这些异步执行路由到其他地方?jOOQ 自身的 CompletionStage 实现也确保一切总是在 ExecutorProvider 提供的 Executor 上执行,与 JDK API 不同,JDK API 总是默认回到 ForkJoinPool(除非最近有所改变)。"
接下来,让我们解决反应式获取。
反应式获取
反应式获取是指结合使用反应式 API 和 jOOQ。由于你正在使用 Spring Boot,你很可能已经熟悉 Project Reactor 反应式库(projectreactor.io/)或 Mono 和 Flux API。所以,不深入细节,让我们举一个在控制器中结合 Flux 和 jOOQ 的例子:
@GetMapping(value = "/employees",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> fetchEmployees() {
return Flux.from(
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
EMPLOYEE.JOB_TITLE, EMPLOYEE.SALARY)
.from(EMPLOYEE))
.map(e -> e.formatHTML())
.delayElements(Duration.ofSeconds(2))
.share();
}
因此,jOOQ 负责从 EMPLOYEE 获取一些数据,而 Flux 负责发布获取到的数据。你可以在 SimpleReactive(适用于 MySQL)中练习这个示例。
那更复杂的例子呢?可以应用于减轻流式管道中数据丢失的重要架构之一是混合消息记录(HML)。想象一下一个用于 Meetup RSVP 的流式管道。为了确保我们不丢失任何 RSVP,我们可以依赖基于接收器的消息记录(RBML)在对其进行任何操作之前将其写入稳定的存储(例如,PostgreSQL)。此外,我们可以依赖基于发送器的消息记录(SBML)在将 RSVP 发送到管道的下一部分之前将其写入稳定的存储(例如,到消息队列)。这是由应用程序业务逻辑处理过的 RSVP,所以它可能和接收到的 RSVP 不同。以下图表示了通过 HML 实现的数据流:
![图 8.7 – 通过 HML 的数据流
![img/B16833_Figure_8.7.jpg]
图 8.7 – 通过 HML 的数据流
基于前面的图,我们可以异步地实现数据的处理和恢复。例如,RBML 部分可以用 jOOQ 表示如下:
public void insertRsvps(String message) {
Flux<RsvpDocument> fluxInsertRsvp =
Flux.from(ctx.insertInto(RSVP_DOCUMENT)
.columns(RSVP_DOCUMENT.ID, RSVP_DOCUMENT.RSVP,
RSVP_DOCUMENT.STATUS)
.values((long) Instant.now().getNano(), message, "PENDING")
.returningResult(RSVP_DOCUMENT.ID, RSVP_DOCUMENT.RSVP,
RSVP_DOCUMENT.STATUS))
.map(rsvp -> new RsvpDocument(rsvp.value1(), rsvp.value2(),
rsvp.value3()));
processRsvp(fluxInsertRsvp);
}
另一方面,SBML 部分可以表示如下:
private void recoverRsvps() {
Flux<RsvpDocument> fluxFindAllRsvps = Flux.from(
ctx.select(RSVP_DOCUMENT.ID, RSVP_DOCUMENT.RSVP,
RSVP_DOCUMENT.STATUS)
.from(RSVP_DOCUMENT))
.map(rsvp -> new RsvpDocument(rsvp.value1(),
rsvp.value2(), rsvp.value3()));
processRsvp(fluxFindAllRsvps);
}
那删除或更新一个 RSVP 呢?对于完整的代码,请查看HML应用程序,该应用程序适用于 PostgreSQL。
摘要
这是一章大章节,涵盖了 jOOQ 最强大的功能之一,即获取和映射数据。正如你所学的,jOOQ 支持广泛的获取和映射数据的方法,从简单的获取到记录映射器,再到花哨的 SQL/JSON 和 SQL/XML,再到令人惊叹的多集支持,最后到懒加载、异步和响应式获取。在下一章中,我们将讨论如何批量处理和批量数据。
第三部分:jOOQ 及其他查询
在本部分,我们更进一步,探讨一些热门话题,例如标识符、批处理、批量处理、分页、乐观锁定和 HTTP 长会话。
在本部分结束时,您将了解如何使用 jOOQ 功能来实现上述主题。
本部分包含以下章节:
-
第九章, CRUD、事务和锁定
-
第十章, 导出、批处理、批量处理和加载
-
第十一章, jOOQ 键
-
第十二章, 分页和动态查询
第九章:CRUD、事务和锁定
在本章中,我们将介绍关于 CRUD 操作、事务和锁定的基本概念,这些概念在几乎任何数据库应用中都被广泛使用。在常见场景中,应用程序有大量的 CRUD 操作,这些操作在显式界定的逻辑事务中执行,并且在某些情况下,它们还需要显式控制对数据的并发访问,以防止竞态条件、丢失更新和其他SQL 现象(或 SQL 异常)。
在本章中,我们将介绍以下主题:
-
CRUD
-
导航(可更新)记录
-
事务
-
锁定
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter09。
CRUD
除了用于表达复杂 SQL 的出色的 DSL 流畅 API 之外,jOOQ 还可以用于表达日常SQL 操作。这些被称为INSERT(插入)、SELECT(选择)、UPDATE(更新)和DELETE(删除),jOOQ 通过一个涉及UpdatableRecord类型的专用 API 来简化它们。换句话说,jOOQ 代码生成器为每个具有主键的表生成一个UpdatableRecord(可以再次从数据库中检索和存储的记录),而不仅仅是简单唯一键的表!没有主键的表(org.jooq.TableRecord)被 jOOQ 正确地认为是不可更新的。你可以很容易地识别一个 jOOQ UpdatableRecord,因为它必须扩展UpdatableRecordImpl类(只需检查你从jooq.generated.tables.records生成的记录)。接下来,jOOQ 公开了一个 CRUD API,允许你直接在这些可更新记录上操作,而不是编写 DSL 流畅查询(这对于涉及多个表的复杂查询更适合)。
如果你需要关于 jOOQ 记录的快速提醒,请查看第三章,jOOQ 核心概念。
重要提示
jOOQ CRUD API 非常适合规范化的数据库,因此对于具有主键(简单或复合)或唯一生命周期的表,主键只插入一次到表中。一旦插入,它就不能更改或删除后重新插入。
然而,正如你所知,jOOQ 试图在任何情况下都向你打招呼,所以如果你需要可更新的主键,那么请依赖Settings.updatablePrimaryKeys()。
jOOQ CRUD API 简化了多个操作,包括插入(insert())、更新(update())、删除(delete())、合并(merge())以及方便的存储(store())。除了这些操作之外,我们还有众所周知的selectFrom(),它对于直接从单个表读取到可更新记录的Result非常有用。
然而,在我们查看几个 CRUD 示例之前,了解一组可以影响可更新记录和 CRUD 操作行为的方法是很重要的。这些方法是 attach()、detach()、original()、changed()、reset() 和 refresh()。
附加/解除可更新记录
大概来说,jOOQ 的可更新记录只是可以独立于数据库存在的 Java 对象,并且可以在内存中进行操作。只要可更新记录不需要与数据库交互,它就可以保持在 Configuration 中,Configuration 中包含连接到该可更新记录将要交互的数据库的坐标。一旦可更新记录被附加,它将保持这种状态,直到相应的 Configuration 存活或被显式调用 detach() 解除。
当我们从数据库中检索可更新记录时,jOOQ 将自动将它们附加到当前使用的 Configuration,并隐式地附加到涉及的数据库连接。此连接可以用于内部后续与检索的可更新记录的数据库交互。
重要提示
严格来说,jOOQ 并不持有 JDBC Connection 的引用,而是持有来自 Configuration 的 ConnectionProvider。在事务或连接池方面,这可能是相关的。例如,如果我们使用 Spring 的 TransactionAwareDataSourceProxy,一个附加的记录可以在一个事务中检索并在另一个事务中透明地存储。
考虑以下读取操作:
SaleRecord sr = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(1L)).fetchSingle();
sr 记录被 jOOQ 自动附加到 ctx 的 Configuration 部分。接下来,我们可以成功执行其他与数据库交互的操作,例如 sr.update()、sr.delete() 等。
现在,让我们考虑一个由客户端创建的新记录,如下所示:
SaleRecord srNew = new SaleRecord(...);
这样的记录不会自动附加到任何现有的 Configuration。它们尚未从数据库中检索,因此 jOOQ 有理由期望你将在必要时(例如,在调用 sr.insert() 之前)明确/手动将它们附加到 Configuration。这可以通过明确调用 DSLContext.attach() 或 UpdatableRecord.attach() 方法来完成,如下所示:
ctx.attach(srNew);
srNew.attach(ctx.configuration());
然而,为了避免显式调用 attach(),我们可以依赖 DSLContext.newRecord() 选项。由于 DSLContext 包含 Configuration 部分,jOOQ 将自动执行附加。因此,这里你应该使用以下代码片段(如果你想从 POJO 中填充记录,则使用 newRecord(Table<R> table, Object o) 风味):
SaleRecord srNew = ctx.newRecord(SALE);
srNew.setFiscalYear(…);
…
一旦 srNew 被附加,我们就可以执行与数据库交互的操作。尝试在解除附加的可更新记录上执行此类操作将导致一个异常,该异常声明 org.jooq.exception.DetachedException:无法执行查询。未配置连接。
可更新记录可以通过 UpdatableRecord.detach() 显式解除:
srNew.detach(); // equivalent to srNew.attach(null);
虽然可更新的记录是可序列化的,但Configuration的底层Connection(或DataSource)是不可序列化的。尽管如此,在序列化之前不需要分离记录。DefaultConfiguration的内部确保任何不是Serializable的(例如,DefaultConnectionProvider)都不会被序列化。尽管如此,在反序列化之后仍然需要重新附加。jOOQ 雷达上的序列化支持最终将被弃用:github.com/jOOQ/jOOQ/issues/2359。
从这一点,不要得出序列化/反序列化记录是日常任务的结论。大多数时候,记录用于填充视图(例如,通过 Thymeleaf)或通过 jOOQ 支持导出为 CSV、JSON、HTML 等,正如您将在下一章中看到的那样。这些操作都不需要显式分离。
什么是原始(可更新)记录?
每个(可更新)记录都持有对其当前值和原始值的引用。
如果记录是从数据库中检索的,那么检索到的值同时代表原始和当前值。接下来,可以在内存中修改当前值,而原始值保持不变。例如,假设以下查询检索了SALE.FISCAL_YEAR字段,其值为2005,并且在检索后将其设置为2002:
SaleRecord sr = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(1L)).fetchSingle();
// or, a little bit more concise
SaleRecord sr = ctx.fetchSingle(SALE, SALE.SALE_ID.eq(1L));
sr.setFiscalYear(2002);
在这一点上,财政年度的原始值是2005,而当前值是2002。在插入/更新记录后,已插入/更新的当前值成为原始值。例如,在更新sr后,财政年度的原始值变为2002,就像当前值一样。这样,sr反映了数据库的最新状态。因此,在更新后,原始值仅反映默认发送到数据库的内容。默认情况下,触发生成的值不会被检索回来。为了实现这一点,需要Settings.returnAllOnUpdatableRecord()。
在新记录的情况下,原始值始终为null,并且直到记录被插入或更新之前保持这种状态。
每当我们需要原始值时,我们可以调用Record.original()。如果没有参数,original()方法返回一个全新的记录,该记录已填充了原始值。如果在调用original()时附加了sr,则srOriginal也会附加;否则,它会被分离:
SaleRecord srOriginal = sr.original();
通过指定一个Field、Name、String或整数(索引)作为参数,我们可以提取某个字段的原始值。以下是一个类型安全和类型不安全的示例:
int fiscalYear = sr.original(SALE.FISCAL_YEAR);
int fiscalYear = (int) sr.original("fiscal_year");
拥有记录的当前值和原始值在手,可以在比较这些值之间或与其他值进行比较后做出决策,创建原始数据和当前数据的并排视图,等等。在捆绑的代码中,称为OriginalRecords(适用于 MySQL),你可以看到一个通过 Thymeleaf 渲染PRODUCT并排视图的示例。相关的 Thymeleaf 代码如下:
<tr>
<td> Product Buy Price:</td>
<td th:text = "${product.original('buy_price')}"/></td>
</tr>
<tr>
<td> Product MSRP:</td>
<td th:text = "${product.original('msrp')}"/></td>
</tr>
你会看到以下类似的内容:

图 9.2 – 在控制台上显示修改后的记录
注意,jOOQ 将 fiscal_year 和 sale 用星号 (*) 标记。这个星号突出了将参与以下 UPDATE 的已更改字段:
sr.update();
在 MySQL 方言中渲染的 SQL 如下(渲染的 UPDATE 依赖于主键):
UPDATE `classicmodels`.`sale`
SET `classicmodels`.`sale`.`fiscal_year` = 2000,
`classicmodels`.`sale`.`sale` = 1111.25
WHERE `classicmodels`.`sale`.`sale_id` = 1
默认情况下,更新一个已经是最新的记录没有任何效果。当然,如果你依赖 changed() 来标记所有/某些字段为 已更改,那么你将强制 jOOQ 执行相应的 UPDATE。你可以在捆绑的代码中通过 Settings.withUpdateUnchangedRecords(UpdateUnchangedRecords) 来练习强制更新。
删除可更新记录
可以通过 UpdatableRecord.delete() 删除检索的可更新记录:
SaleRecord sr = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(5L)).fetchSingle();
sr.delete();
// MySQL rendered SQL
DELETE FROM `classicmodels`.`sale` WHERE `classicmodels`
.`sale`.`sale_id` = 5
如您所见,渲染的 DELETE 依赖于主键(或主要唯一键)。删除后,被删除记录的所有字段都会自动标记为 已更改,因此您可以轻松地通过调用 insert() 再次插入它。
合并可更新的记录
每当我们想要对一个可更新的记录(全新或从数据库中检索到的)执行 MERGE 语句时,我们可以调用 UpdatableRecord.merge()。在这种情况下,jOOQ 会渲染一个 INSERT ... ON DUPLICATE KEY UPDATE(这取决于使用的方言,是模拟的),因此它将选择在 INSERT 和 UPDATE 之间的任务委托给数据库。以下是一个示例:
SaleRecord srNew = ctx.newRecord(SALE);
srNew.setFiscalYear(2000);
...
srNew.merge();
在这种情况下,srNew 将被插入。以下是一个另一个示例:
SaleRecord srFetched = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(1L)).fetchSingle();
srFetched.setFiscalYear(2005);
...
srFetched.merge();
在这里,srFetched 将根据主键进行更新。实际上,jOOQ 将渲染一个更新行的 SQL 语句,无论哪个(唯一)键值已经存在。
存储可更新的记录
存储可更新的记录可以通过调用 UpdatableRecord.store() 方法来完成。此方法根据主键的状态导致 INSERT 或 UPDATE。渲染 INSERT 或 UPDATE 的决定由 jOOQ 而不是数据库做出,就像在 MERGE 的情况下一样。
通常,为新可更新的记录调用 store() 会导致一个 INSERT:
SaleRecord srNew = ctx.newRecord(SALE);
srNew.setFiscalYear(2000);
...
srNew.store(); // jOOQ render an INSERT
如果可更新的记录是从数据库中检索的,并且其主键没有更改,那么 jOOQ 将渲染一个 UPDATE:
SaleRecord srFetched = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(5L)).fetchSingle();
srFetched.setFiscalYear(2005);
srFetched.changed(SALE.SALE_, true);
...
srFetched.store(); // jOOQ render an UPDATE
如果可更新的记录是从数据库中检索的,并且其主键已更改,那么 jOOQ 将渲染一个 INSERT:
srFetched.setSaleId(…);
srFetched.store(); // jOOQ render an INSERT
然而,我们仍然可以通过 withUpdatablePrimaryKeys(true) 强制主键的 UPDATE:
DSLContext derivedCtx = ctx.configuration().derive(
new Settings().withUpdatablePrimaryKeys(true)).dsl();
SaleRecord sr = derivedCtx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(7L)).fetchSingle();
sr.setSaleId(...);
sr.store(); // jOOQ render an UPDATE of primary key
然而,正如 Lukas Eder 分享的那样:“我认为值得指出的是,更新主键与所有规范化原则背道而驰。它是为了那些有很好的理由这样做的情况而引入的,而这些理由非常罕见(通常是数据迁移或修复损坏的数据,但即使在这种情况下,他们可能更倾向于使用 SQL 语句而不是可更新的记录)。”
您可以在 SimpleCRUDRecords(适用于 MySQL 和 PostgreSQL)中看到这些示例。
另一方面,如果您更喜欢使用 POJO 和 jOOQ 的 DAO,那么您会想查看 SimpleDaoCRUDRecords(适用于 MySQL 和 PostgreSQL)中的示例。这些示例依赖于 DAO 的 insert()、update()、delete() 和 merge()。此外,您将看到 withReturnRecordToPojo() 设置在起作用。接下来,让我们专注于在 Web 应用程序中使用可更新的记录。
在 HTTP 会话中使用可更新的记录
jOOQ 的可更新记录可用于 Web 应用程序,换句话说,在跨越无状态 HTTP 协议请求的会话中使用。接下来,我们将开发几个 Spring Boot 示例,旨在强调我们迄今为止所学的内容。
使用 insert()、update() 和 delete()
让我们尝试构建一个使用可更新记录和insert()、update()和delete()的 Spring Boot 示例应用程序。在依赖 Spring MVC 设计模式的同时,让我们考虑以下场景:我们的主要目标是提供一个属于特定客户相同付款(PAYMENT)的所有银行交易(BANK_TRANSACTION)的列表。用户应该能够插入一个新的银行交易,并删除或修改现有的交易。
列出所有银行交易
显示银行交易的页面应如下所示(transactions.html):

图 9.3 – 某个付款的所有银行交易
让我们从控制器端点开始,该端点应被访问以生成前面截图所示的输出:
@GetMapping("/transactions")
public String loadAllBankTransactionOfCertainPayment(
SessionStatus sessionStatus, Model model) {
sessionStatus.setComplete();
model.addAttribute(ALL_BANK_TRANSACTION_ATTR,
classicModelsService
.loadAllBankTransactionOfCertainPayment());
return "transactions";
}
在高亮显示的代码中,我们调用负责访问执行查询以获取特定付款的所有交易的存储库的服务。这个查询相当简单(当然,在现实中,你不会硬编码CUSTOMER_NUMBER和CHECK_NUMBER的值——这些可以代表诸如登录付款凭证之类的信息):
public Result<BankTransactionRecord>
fetchAllBankTransactionOfCertainPayment() {
return ctx.selectFrom(BANK_TRANSACTION)
.where(BANK_TRANSACTION.CUSTOMER_NUMBER.eq(333L)
.and(BANK_TRANSACTION.CHECK_NUMBER.eq("NF959653")))
.fetch();
}
下一步,从前面列出的控制器端点返回的Result<BankTransactionRecord>被存储在模型中(Spring Boot 的Model)作为名为all的请求属性(ALL_BANK_TRANSACTION_ATTR = "all")。要渲染从该控制器端点返回的页面(transactions.html),我们可以依赖流行的 Thymeleaf 模板引擎(当然,你可以使用任何其他模板引擎):
<tr th:each="t : ${all}">
<td><span th:text="${t.transactionId}">ID</span></td>
<td><span th:text="${t.bankName}">Bank Name</span></td>
...
<td><span th:text="${t.status}">Status</span></td>
</tr>
从返回的页面(transactions.html),我们可以选择插入一个新的交易或修改现有的交易。
插入一个新的银行交易
插入一个新的银行交易可以通过以下方式渲染链接:
<a href="/newbanktransaction">Insert new bank transaction</a>
此链接到达一个控制器端点,其外观如下:
@GetMapping("/newbanktransaction")
public String newBankTransaction(Model model) {
model.addAttribute(NEW_BANK_TRANSACTION_ATTR,
new BankTransactionRecord());
return "newtransaction";
}
因此,此控制器端点创建了一个新的BankTransactionRecord,通过NEW_BANK_TRANSACTION_ATTR请求属性存储在模型中。返回的页面newtransaction.html被渲染如下:

图 9.4 – 创建一个新的银行交易
点击保存按钮会触发一个POST请求,该请求到达以下控制器端点(/new):
@PostMapping("/new")
public String newBankTransaction(
@ModelAttribute BankTransactionRecord btr,
RedirectAttributes redirectAttributes) {
classicModelsService.newBankTransaction(btr);
redirectAttributes.addFlashAttribute(
INSERT_DELETE_OR_UPDATE_BANK_TRANSACTION_ATTR, btr);
return "redirect:success";
}
因此,Spring Boot 使用提交的数据填充btr记录,并将其插入到数据库中(在插入之前,在服务方法(此处未列出)中,我们通过btr.setCustomerNumber()和btr.setCheckNumber()将这个新交易与相应的付款关联起来):
@Transactional
public int newBankTransaction(BankTransactionRecord btr) {
ctx.attach(btr);
return btr.insert();
}
由于这是一个新的银行交易,我们必须在插入之前将其附加。
更新银行交易
让我们考虑更新银行交易意味着一个四步向导。对于简单向导,我们可以在向导的最后一步提交一个单独的 <form/>。然而,对于动态向导,我们必须为每个面板使用一个 <form/>,因为我们必须在每个步骤提交数据以决定下一个面板以及它将包含什么。因此,在这种情况下,我们必须实现一个能够存储用户数据并在面板之间来回导航的长 HTTP 会话。通常,这是通过通过客户端的 HTTP 会话存储数据来完成的。
让我们尽可能保持简单,并假设四步向导看起来如下:

图 9.5 – 四步向导
在进入此向导之前,我们必须点击与我们要编辑的银行交易对应的 修改 链接。这将发送交易 ID 并达到以下控制器端点:
@GetMapping("/editbankname/{id}")
public String loadBankTransaction(
@PathVariable(name = "id") Long id, Model model) {
model.addAttribute(BANK_TRANSACTION_ATTR,
classicModelsService.loadBankTransaction(id));
return "redirect:/editbankname";
}
可以通过以下存储库方法获取 BankTransactionRecord:
public BankTransactionRecord fetchBankTransaction(Long id) {
return ctx.selectFrom(BANK_TRANSACTION)
.where(BANK_TRANSACTION.TRANSACTION_ID.eq(id))
.fetchSingle();
}
由于这是一个应该在向导面板之间持续存在的交易,我们必须通过会话属性 BANK_TRANSACTION_ATTR = "bt" 在模型中存储它。接下来,我们必须返回向导的第一个面板。
编辑银行名称
一旦我们编辑了银行名称,我们必须点击 下一步 提交数据。这将达到以下控制器端点:
@PostMapping("/name")
public String editBankName(
@ModelAttribute(BANK_TRANSACTION_ATTR)
BankTransactionRecord btr) {
return "redirect:editbankiban";
}
在这里,我们只是允许 Spring Boot 同步 btr 会话记录与提交的数据。接下来,我们必须返回第二个面板。
编辑 IBAN
一旦我们编辑了银行名称,我们必须编辑 IBAN 并点击 下一步(我们也可以点击 上一步 再次编辑银行名称)。 在编辑 IBAN 后,提交的数据达到控制器端点:
@PostMapping("/iban")
public String editBankIban(
@ModelAttribute(BANK_TRANSACTION_ATTR)
BankTransactionRecord btr) {
return "redirect:editcardtype";
}
再次,我们允许 Spring Boot 同步 btr 会话记录与提交的数据。接下来,我们必须返回第三个面板。
编辑卡片类型
一旦我们编辑了银行的 IBAN,我们必须选择卡片类型并点击 下一步(我们也可以点击 上一步 再次编辑银行 IBAN)。 在选择卡片类型后,提交的数据达到控制器端点:
@PostMapping("/cardtype")
public String editCardType(
@ModelAttribute(BANK_TRANSACTION_ATTR)
BankTransactionRecord btr) {
return "redirect:editbanktransfer";
}
再次,我们允许 Spring Boot 同步 btr 会话记录与提交的数据。接下来,我们必须返回最后一个面板。
编辑转账金额
最后,我们必须编辑转账金额并将其提交到控制器端点:
@PostMapping("/transfer")
public String updateBankTransfer(
@ModelAttribute(BANK_TRANSACTION_ATTR)
BankTransactionRecord btr, SessionStatus sessionStatus,
RedirectAttributes redirectAttributes) {
classicModelsService.updateBankTransaction(btr);
redirectAttributes.addFlashAttribute(
INSERT_DELETE_OR_UPDATE_BANK_TRANSACTION_ATTR, btr);
sessionStatus.setComplete();
return "redirect:success";
}
在存储库方法中使用了 UPDATE 方法,如下所示:
@Transactional
public int updateBankTransaction(BankTransactionRecord btr) {
return btr.update();
}
最后,我们必须清理 HTTP 会话以删除可更新的记录。
重置向导数据
任何向导的常见功能是提供一个 重置 按钮来将当前面板或整个向导的数据重置为最新保存的数据。我们的 重置 按钮依赖于 jOOQ 的 reset() 方法来重置向导(所有三个面板):
@GetMapping("/reset/{page}")
public String reset(
@PathVariable(name = "page") String page, Model model) {
if (model.containsAttribute(BANK_TRANSACTION_ATTR)) {
((BankTransactionRecord) model.getAttribute(
BANK_TRANSACTION_ATTR)).reset();
}
return "redirect:/" + page;
}
当然,您可以使用 reset(Field/Name/String/int) 来实现按面板重置功能。最后,让我们删除一个银行交易。
删除银行交易
如图 9.5所示,我们的向导中的每个面板都包含一个删除按钮,它允许我们删除这笔银行交易。其控制器端点的代码如下:
@GetMapping("/delete")
public String deleteBankTransaction(
SessionStatus sessionStatus, Model model,
RedirectAttributes redirectAttributes) {
...
BankTransactionRecord btr = (BankTransactionRecord)
model.getAttribute(BANK_TRANSACTION_ATTR);
classicModelsService.deleteBankTransaction(btr);
sessionStatus.setComplete();
...
}
在以下存储库方法中,DELETE是通过调用delete()来实现的:
@Transactional
public int deleteBankTransaction(BankTransactionRecord btr) {
return btr.delete();
}
完整的代码称为CRUDRecords。如果您更喜欢使用 POJOs 和 jOOQ 的 DAO,请查看DaoCRUDRecords和 REST 版本(用于 Postman、ARC 等),该版本称为DaoCRUDRESTRecords。这三个应用程序都适用于 MySQL。为了简洁,我们省略了任何验证和错误处理代码。
使用 merge()与 store()的比较
让我们考虑以下场景:我们已经加载并显示了某个客户(例如,PAYMENT.CUSTOMER_NUMBER.eq(103L))的付款(PAYMENT)。用户应该能够为此客户插入新的付款或更新现有付款的金额。为了解决这个问题,我们有两种几乎相同的方法。这些方法在下面的屏幕截图中显示:

图 9.6 – 插入/更新付款
关于左侧的设计,要插入一个新的付款,我们可以简单地输入一个新的(唯一的)支票号码(例如,通过短信接收)和相应的发票金额。要更新现有付款的发票金额,我们必须从底部表格中输入其当前的支票号码(例如,要更新表格中的第二个付款,我们必须输入支票号码 JM555205)。
关于右侧设计,要插入一个新的付款,我们只需输入发票金额;支票号码由应用程序自动生成并预先填写。然而,要更新现有付款的发票金额,我们必须首先通过底部表格中的相应加载链接加载付款。这将从数据库中检索相应的付款,以便我们可以输入新的金额值并更新它。
通过 merge()实现左侧设计
让我们专注于左侧设计。在用户提交付款表单后,Spring Boot 创建一个新的PaymentRecord并用提交的数据填充它。接下来,根据提交的支票号码,我们必须确定这是一笔新付款还是对现有付款的更新,以执行INSERT或UPDATE。因此,现在是merge()发挥作用并生成一个将选择INSERT或UPDATE的任务委托给数据库的 SQL 的时候了:
@PostMapping("/merge")
public String mergePayment(PaymentRecord pr) {
classicModelsService.mergePayment(pr);
return "redirect:payments";
}
@Transactional
public int mergePayment(PaymentRecord pr) {
ctx.attach(pr);
return pr.merge();
}
那就是所有重要的代码!请注意,在合并之前,我们需要附加相关的PaymentRecord。请记住,Spring Boot 已经创建了此记录,因此它没有附加到任何Configuration。
查看此代码的完整应用程序,该应用程序称为MergeRecords。如果您更喜欢使用 POJOs 和 jOOQ 的 DAO,请查看DaoMergeRecords。这两个应用程序都适用于 MySQL。
通过 store()实现右侧设计
如果我们希望实现右侧设计,那么我们必须首先准备一个新的 PaymentRecord(例如,我们必须生成 Check Number)并通过 HTTP 会话属性(PAYMENT_ATTR)存储它。这个 PaymentRecord 将返回给用户。然而,如果用户想要更新现有付款的 Invoice Amount,那么他们可以选择点击底部表格中的相应 Load 链接。以下查询可以用来获取相关的 RecordPayment:
public PaymentRecord fetchPayment(Long nr, String ch) {
return ctx.selectFrom(PAYMENT)
.where(row(PAYMENT.CUSTOMER_NUMBER, PAYMENT.CHECK_NUMBER)
.eq(row(nr, ch)))
.fetchSingle();
}
获取的 PaymentRecord 覆盖了来自 HTTP 会话的记录,并将其返回给用户。当用户提交数据时,Spring Boot 会将存储在 PAYMENT_ATTR(可以是新的 PaymentRecord 或获取的 PaymentRecord)中的 PaymentRecord 值与提交的数据同步。这次,我们可以让 jOOQ 通过 store() 方法在 INSERT 和 UPDATE 之间进行选择,因为此方法区分了新的 PaymentRecord 和获取的 PaymentRecord 并相应地执行:
@PostMapping("/store")
public String storePayment(SessionStatus sessionStatus,
@ModelAttribute(PAYMENT_ATTR) PaymentRecord pr) {
pr.setCachingDate(LocalDateTime.now());
classicModelsService.storePayment(pr);
sessionStatus.setComplete();
return "redirect:payments";
}
@Transactional
public int storePayment(PaymentRecord pr) {
ctx.attach(pr);
return pr.store();
}
使用 store() 的应用程序名为 StoreRecords(适用于 MySQL)。现在,让我们继续讨论如何导航(可更新的)记录。
导航(可更新的)记录
jOOQ 提供了几个仅适用于附加(可更新的)记录的导航方法(TableRecord 和 UpdatableRecord)。要使用这些方法,请考虑以下注意事项。
重要提示
虽然这些方法非常方便/吸引人,但它们也带来了很大的 N+1 风险。UpdatableRecord 对于 CRUD 非常好,但如果您不使用 CRUD,那么您不应该使用 UpdatableRecord。最好只投影所需的列,并尝试使用连接或其他 SQL 工具从多个表中获取数据。
这些方法基于外键引用进行导航。例如,附上 DepartmentRecord 后,我们可以通过 fetchParent(ForeignKey<R, O> key) 导航其父级(OFFICE),如下所示:
public OfficeRecord fetchOfficeOfDepartment(
DepartmentRecord dr) {
return dr.fetchParent(Keys.DEPARTMENT_OFFICE_FK);
// or, Keys.DEPARTMENT_OFFICE_FK.fetchParent(dr);
}
Keys.DEPARTMENT_OFFICE_FK 外键是由 jOOQ 代码生成器根据我们的 CREATE TABLE DDL 生成的。在 MySQL 方言中,jOOQ 生成的 SQL 如下所示:
SELECT
`classicmodels`.`office`.`office_code`,
...
`classicmodels`.`office`.`location`
FROM
`classicmodels`.`office`
WHERE
`classicmodels`.`office`.`office_code` in (?)
您也可以通过 parent() 方法获取 Table<OfficeRecord>:
Table<OfficeRecord> tor =
dr.parent(Keys.DEPARTMENT_OFFICE_FK);
Table<OfficeRecord> tor =
Keys.DEPARTMENT_OFFICE_FK.parent(dr);
接下来,附上 OfficeRecord 后,我们可以通过 fetchChildren(ForeignKey<O,R> key) 获取员工(EMPLOYEE),如下所示:
public Result<EmployeeRecord>
fetchEmployeesOfOffice(OfficeRecord or) {
return or.fetchChildren(Keys.EMPLOYEE_OFFICE_FK);
// or, Keys.EMPLOYEE_OFFICE_FK.fetchChildren(or);
}
这次,为 MySQL 方言生成的 SQL 如下所示:
SELECT
`classicmodels`.`employee`.`employee_number`,
...
`classicmodels`.`employee`.`monthly_bonus`
FROM
`classicmodels`.`employee`
WHERE
`classicmodels`.`employee`.`office_code` in (?)
您也可以通过 children() 方法获取 Table<OfficeRecord>(使用 children() 通常比 fetchChildren() 更可取,因为它鼓励编写查询而不是直接导航 UpdatableRecord):
Table<EmployeeRecord> ter =
or.children(Keys.EMPLOYEE_OFFICE_FK);
Table<EmployeeRecord> ter =
Keys.EMPLOYEE_OFFICE_FK.children(or);
接下来,我们可以重用 fetchChildren() 来获取特定员工(EmployeeRecord)的客户(CUSTOMER)。这将导致获取该 EmployeeRecord 的所有 CustomerRecord。最后,附上 CustomerRecord 后,我们可以通过 fetchChild(ForeignKey<O, R> key) 获取其详细信息(CUSTOMERDETAIL),如下所示:
public CustomerdetailRecord
fetchCustomerdetailOfCustomer(CustomerRecord cr) {
return cr.fetchChild(Keys.CUSTOMERDETAIL_CUSTOMER_FK);
}
为 MySQL 方言生成的 SQL 如下所示:
SELECT
`classicmodels`.`customerdetail`.`customer_number`,
...
`classicmodels`.`customerdetail`.`country`
FROM
`classicmodels`.`customerdetail`
WHERE
`classicmodels`.`customerdetail`.`customer_number` in (?)
在捆绑的代码(NavigationRecords,适用于 MySQL)中,你可以看到所有这些方法协同工作以获得类似以下的内容:
![Figure 9.7 – 在记录之间导航]
![img/B16833_Figure_9.7.jpg]
图 9.7 – 在记录之间导航
这些方法对于循环处理记录的父/子关系并执行某些操作也非常方便。以下是一个使用 fetchParent() 来获取每个销售额少于 2,000 的 SaleRecord 的 EmployeeRecord 详细信息的示例:
for (SaleRecord sale : ctx.fetch(SALE, SALE.SALE_.lt(2000d))){
if ("Sales Rep".equals(sale.fetchParent(
Keys.SALE_EMPLOYEE_FK).getJobTitle())) {
sale.delete();
}
}
在前面的示例中,每次调用 fetchParent() 都会执行一个单独的 SELECT,这远远不是一个好的选择。然而,一个在这个情况下有帮助的有趣方法是 fetchParents(),它可以一次性获取记录列表的所有父记录。这意味着我们可以将之前的查询重写如下:
List<SaleRecord> sales
= ctx.fetch(SALE, SALE.SALE_.lt(2000d));
List<EmployeeRecord> employees
= Keys.SALE_EMPLOYEE_FK.fetchParents(sales);
for (SaleRecord sale : sales) {
for (EmployeeRecord employee : employees) {
if (Objects.equals(sale.getEmployeeNumber(),
employee.getEmployeeNumber()) && "Sales Rep".equals(
employee.getJobTitle())) {
sale.delete();
break;
}
}
}
如果你需要 Table<EmployeeRecord>,则使用 parents():
Table<EmployeeRecord> employeesTable
= Keys.SALE_EMPLOYEE_FK.parents(sales);
重要提示
注意,从性能角度来看,这类循环真的很糟糕!
如果客户端没有业务逻辑,它应该是一个带有半连接(例如,一个 IN 谓词)的单个 DELETE 语句。因此,不要将这些循环示例当作字面意思来理解。我知道这种方法感觉更容易,但我强烈建议避免使用它。不要在应用程序中到处实现这样的循环,然后抱怨 jOOQ 很慢,就像人们抱怨 Hibernate 很慢时这些导航循环只是错误的。
任何人都应该逐行处理数据的唯一原因是每一行都需要非常复杂的业务逻辑,这些逻辑无法用 SQL 表达或以其他方式推入数据库。人们在所有语言中都会犯这个错误,包括 PL/SQL。他们循环遍历行是因为这样做方便,他们更喜欢 3GL 而不是 SQL-4GL,然后他们逐行运行查询,因为他们可以这样做。因此,为了证明之前的循环是合理的,我们至少需要添加一些 businessLogicHere(saleRecord) 方法调用,以暗示在这个特定情况下逐行方法是有必要的。
你可以在 NavigationParentsRecords(适用于 MySQL)中找到这些示例。接下来,让我们专注于使用显式事务和 jOOQ 查询。
事务
在其他好处中,事务为我们提供了 ACID 属性。我们可以区分 只读 和 读写 事务,不同的隔离级别,不同的传播策略等等。虽然 Spring Boot 支持一个全面的交易 API(Spring TX),通常通过 @Transactional 和 TransactionTemplate 使用,但 jOOQ 提供了一个简单的交易 API(以及一个 org.jooq.TransactionProvider SPI),它非常适合流畅风格的上下文。
以下图表突出了此 SPI 的主要实现:
![Figure 9.8 – jOOQ transaction providers]
![img/B16833_Figure_9.8.jpg]
图 9.8 – jOOQ 事务提供者
从 jOOQ 3.17 开始,我们支持 R2DBC 中的事务。因此,jOOQ 3.17 带来了对反应式事务的支持。
主要地,jOOQ API 用于阻塞事务的使用方式如下:
ctx.transaction(configuration -> {
DSL.using(configuration)...
// or, configuration.dsl()...
}
var result = ctx.transactionResult(configuration -> {
return DSL.using(configuration)...
// or, return configuration.dsl()...
}
在这里,我们有transaction(TransactionalRunnable),它返回void,以及transactionResult(TransactionalCallable)用于返回结果。前者将事务性代码包装在 jOOQ 的org.jooq.TransactionalRunnable函数式接口中,而后者将事务性代码包装在 jOOQ 的org.jooq.TransactionalCallable函数式接口中。
重要提示
注意,在 jOOQ 事务内部,您必须使用从给定(configuration,而不是ctx(注入的DSLContext))获得的DSLContext。
SpringTransactionProvider
在 Spring Boot 的上下文中,jOOQ 将处理事务(开始、提交和回滚)的任务委托给SpringTransactionProvider,这是org.jooq.TransactionProvider SPI 的一个实现,旨在允许 Spring 事务与 JOOQ 一起使用。默认情况下,您将获得一个没有名称(null)的读写事务,其传播行为设置为PROPAGATION_NESTED,隔离级别设置为底层数据库的默认隔离级别;即ISOLATION_DEFAULT。
如果您想解耦SpringTransactionProvider(例如,为了避免 Spring Boot 和 JOOQ 之间的潜在不兼容性),则可以使用以下代码:
// affects ctx
ctx.configuration().set((TransactionProvider) null);
// create derived DSLContext
ctx.configuration().derive((TransactionProvider) null).dsl();
一旦解耦了SpringTransactionProvider,jOOQ 将通过 jOOQ 的DefaultTransactionProvider和DefaultConnectionProvider执行事务,并将自动提交模式设置为false(如果事务之前是true,则 jOOQ 将在事务后恢复它)。DefaultTransactionProvider支持通过 JDBC 的java.sql.Savepoint实现的嵌套事务。在第十八章 jOOQ SPI(提供者和监听器)中,您将学习如何实现TransactionProvider,但现在,让我们看看一些 jOOQ 事务的例子。让我们从一个简单的、突出提交/回滚的事务开始:
ctx.transaction(configuration -> {
DSL.using(configuration).delete(SALE)
.where(SALE.SALE_ID.eq(1L)).execute();
DSL.using(configuration).insertInto(TOKEN)
.set(TOKEN.SALE_ID, 1L).set(TOKEN.AMOUNT, 1000d)
.execute();
// at this point transaction should commit, but the error
// caused by the previous INSERT will lead to rollback
});
如果您想处理/防止回滚,则可以将事务性代码包装在try-catch块中,并根据您的考虑行事;如果您想做一些工作(例如,做一些清理工作)然后回滚,那么只需在catch块的末尾抛出异常。否则,通过捕获RuntimeException,我们可以在执行 jOOQ 的 SQL 语句时发生错误时防止回滚:
ctx.transaction(configuration -> {
try {
// same DMLs as in the previous example
} catch (RuntimeException e) {
System.out.println("I've decided that this error
doesn't require rollback ...");
}
});
jOOQ 嵌套事务看起来像套娃。我们通过嵌套调用transaction()/transactionResult()来嵌套事务性代码。在这里,事务将由 jOOQ 自动使用保存点来界定。当然,没有人阻止将这些 lambda 表达式提取到方法中,并将它们作为高阶函数组合起来,就像您可以将 Spring 注解的事务性方法组合起来一样。
下面是一个嵌套两个 jOOQ 事务的示例:
public void nestedJOOQTransaction() {
ctx.transaction(outer -> {
DSL.using(outer).delete(SALE) // or, outer.dsl()
.where(SALE.SALE_ID.eq(2L)).execute();
// savepoint created
DSL.using(outer).transaction(inner -> {
DSL.using(inner).insertInto(TOKEN) // or, inner.dsl()
.set(TOKEN.SALE_ID, 1L)
.set(TOKEN.AMOUNT, 1000d).execute();
});
});
}
默认情况下,如果在事务中发生错误,则后续事务(内部事务)将不会执行,所有外部事务都将回滚。但有时,我们可能只想回滚当前事务,而不影响外部事务,如下例所示:
ctx.transaction(outer -> {
try {
DSL.using(outer).delete(SALE)
.where(SALE.SALE_ID.eq(1L)).execute();
// savepoint created
try {
DSL.using(outer)
.transaction(inner -> {
DSL.using(inner).insertInto(TOKEN)
.set(TOKEN.SALE_ID, 1L)
.set(TOKEN.AMOUNT, 1000d).execute();
});
} catch (RuntimeException e) { throw e; }
} catch (RuntimeException e) {
System.out.println("I've decided that this error doesn't
require rollback of the outer transaction ...");
// throw e; // rollback
}
});
您可以在JOOQTransaction(适用于 MySQL)中查看这些示例。
ThreadLocalTransactionProvider
另一个 jOOQ 内置事务提供者是ThreadLocalTransactionProvider。此提供者实现了线程绑定的事务语义。换句话说,事务及其关联的Connection永远不会离开启动事务的线程。
ThreadLocalTransactionProvider的一个重要要求是我们必须直接将自定义的ConnectionProvider实现传递给此提供者,而不是将其传递给Configuration。我们可以编写自己的CustomProvider或依赖于 jOOQ 提供的内置实现,如MockConnectionProvider(用于测试)、DefaultConnectionProvider、DataSourceConnectionProvider或NoConnectionProvider。
例如,如果我们选择DataSourceConnectionProvider,那么在一个 Spring Boot 应用程序中,我们可以使用 Spring Boot 已经准备好的DataSource(例如,HikariDataSource),如下所示:
@Configuration
public class JooqConfig {
@Bean
@ConditionalOnMissingBean(org.jooq.Configuration.class)
public DefaultConfiguration jooqConfiguration(
JooqProperties properties, DataSource ds) {
final DefaultConfiguration defaultConfig =
new DefaultConfiguration();
final ConnectionProvider cp =
new DataSourceConnectionProvider(ds);
defaultConfig
.set(properties.determineSqlDialect(ds))
.set(new ThreadLocalTransactionProvider(cp, true));
/* or, as a derived configuration
final org.jooq.Configuration derivedConfig = defaultConfig
.derive(properties.determineSqlDialect(ds))
.derive(new ThreadLocalTransactionProvider(cp, true));
*/
return defaultConfig;
}
}
或者,如果您正在使用 Spring Boot 2.5.0+,那么您可以利用DefaultConfigurationCustomizer功能接口。此接口定义了一个名为customize(DefaultConfiguration configuration)的方法,这是一种自定义 jOOQ 的DefaultConfiguration的便捷方式:
@Configuration
public class JooqConfig
implements DefaultConfigurationCustomizer {
private final DataSource ds;
public JooqConfig(DataSource ds) {
this.ds = ds;
}
@Override
public void customize(DefaultConfiguration configuration) {
configuration.set(new ThreadLocalTransactionProvider(
new DataSourceConnectionProvider(ds), true));
}
}
完成!现在,我们可以注入由 Spring Boot 根据我们的Configuration构建的DSLContext信息,并利用线程绑定的事务语义,这通常是 Spring 所使用的。您可以通过查看ThreadLocalTransactionProvider{1,2}来查看示例,该示例适用于 MySQL。
接下来,让我们来谈谈 jOOQ 的异步事务。
jOOQ 异步事务
虽然transaction()和transactionResult()是同步的,但 jOOQ 也有transactionAsync()和transactionResultAsync(),可以用来构建异步事务。这里有两组独立的异步事务——它们在并发线程中运行。第一个事务提交,而第二个事务回滚:
// this transaction commits
@Async
public CompletableFuture<Integer>
executeFirstJOOQTransaction() {
return ctx.transactionResultAsync(configuration -> {
int result = 0;
result += DSL.using(configuration).insertInto(TOKEN)
.set(TOKEN.SALE_ID, 1L).set(TOKEN.AMOUNT, 500d)
.execute();
result += DSL.using(configuration).insertInto(TOKEN)
.set(TOKEN.SALE_ID, 1L).set(TOKEN.AMOUNT, 1000d)
.execute();
return result;
}).toCompletableFuture();
}
// this transaction is roll backed
@Async
public CompletableFuture<Integer>
executeSecondJOOQTransaction() {
return ctx.transactionResultAsync(configuration -> {
int result = 0;
result += DSL.using(configuration).delete(SALE)
.where(SALE.SALE_ID.eq(2L)).execute();
result += DSL.using(configuration).insertInto(TOKEN)
.set(TOKEN.SALE_ID, 2L).set(TOKEN.AMOUNT, 1000d)
.execute();
return result;
}).toCompletableFuture();
}
如果您不想依赖于默认的Executor(ForkJoinPool.commonPool()),那么请分别使用transactionAsync(Executor exctr, TransactionalRunnable tr)或transactionResultAsync(Executor exctr, TransactionalCallable<T> tc)。但与CompletableFuture不同,jOOQ 会在其CompletionStage实现中记住Executor,这样就不需要在每个随后的异步调用中提供它。
然而,异步事务与 Spring 配合得非常糟糕,因为 Spring 通常假设线程绑定事务。前往github.com/spring-projects/spring-boot/issues/24049查看关于此问题的讨论。
查看JOOQTransactionAsync中的完整代码(适用于 MySQL)。接下来,让我们看看使用/选择@Transactional或 jOOQ 事务 API 的一些示例。
@Transactional与 jOOQ 事务 API 的比较
一开始,我希望强调一个重要的注意事项(你们中的大多数人可能已经知道并尊重这些声明,但快速提醒总是受欢迎的)。
重要注意事项
非事务上下文指的是没有显式事务边界的上下文,并不是指没有物理数据库事务的上下文。所有数据库语句都在物理数据库事务的上下文中执行。
如果没有指定事务的显式边界(通过@Transactional、TransactionTemplate、jOOQ 事务 API 等),jOOQ可能为每个语句使用不同的数据库连接。jOOQ 是否为每个语句使用不同的连接由ConnectionProvider定义。这个声明对DataSourceConnectionProvider(即使在那时,也取决于DataSource)是正确的,但对DefaultConnectionProvider是错误的。在最坏的情况下,这意味着定义逻辑事务的语句无法受益于 ACID,并且容易导致竞争条件错误和 SQL 现象。每个语句都在单独的事务中执行(自动提交模式),这可能会导致数据库连接获取请求率很高,这并不好!在中等/大型应用程序中,通过缩短事务来减少数据库连接获取请求率可以维持性能,因为您的应用程序将准备好应对高流量(大量并发请求)。
永远不要将@Transactional/TransactionTemplate和 jOOQ 事务 API 组合起来解决共同的任务(当然,这也适用于 Java/Jakarta EE 事务)。这可能会导致意外的行为。只要 Spring 事务和 jOOQ 事务没有交错,就可以在同一个应用程序中使用它们。
使用 Spring 事务的最佳方式仅包括在您的仓库/服务类上使用@Transactional(readOnly=true)注解,并且仅在允许执行写语句的方法上显式设置@Transactional。然而,如果相同的仓库/服务也使用 jOOQ 事务,那么您应该显式注解每个方法,而不是类本身。这样,您就可以避免在显式使用 jOOQ 事务的方法中继承@Transactional(readOnly=true)。
现在,让我们考虑几个旨在揭示使用事务最佳实践的示例。让我们从以下代码片段开始:
public void fetchWithNoTransaction() {
ctx.selectFrom(SALE).fetchAny();
ctx.selectFrom(TOKEN).fetchAny();
}
此方法在非事务性上下文中运行,并执行两个读取语句。每个读取操作都由数据库在单独的物理事务中执行,这需要单独的数据库连接。请注意,这可能并不总是正确的,这取决于ConnectionProvider。依赖@Transactional(readOnly=true)要更好:
@Transactional(readOnly=true)
public void fetchWithTransaction() {
ctx.selectFrom(SALE).fetchAny();
ctx.selectFrom(TOKEN).fetchAny();
}
这次,使用单个数据库连接和单个事务。readOnly带来了许多优势,包括你的团队成员不会意外地添加写语句(这种尝试会导致错误),只读事务可以在数据库级别进行优化(这是数据库供应商特定的),你必须显式设置预期的事务隔离级别,等等。
此外,没有事务并将自动提交设置为true只有在执行单个只读 SQL 语句时才有意义,但这不会带来任何显著的好处。因此,即使在这样的情况下,也最好依赖显式(声明性)事务。
然而,如果你认为不需要readOnly=true标志,那么以下代码也可以在 jOOQ 事务中执行(默认情况下,这是一个读写事务):
public void fetchWithTransaction() {
ctx.transaction(configuration -> {
DSL.using(configuration).selectFrom(SALE).fetchAny();
DSL.using(configuration).selectFrom(TOKEN).fetchAny();
// Implicit commit executed here
});
}
注意,与 Spring 的TransactionTemplate(也可以使用)一样,jOOQ 事务可以严格界定事务性代码。换句话说,@Transactional注解在进入方法时立即获取数据库连接并开始事务。然后,它在方法结束时提交事务。这意味着@Transactional方法中可能的事务性代码(不需要在事务中运行的业务逻辑代码)仍然在当前事务中运行,这可能导致长时间运行的事务。另一方面,jOOQ 事务(就像TransactionTemplate)允许我们将事务性代码隔离和编排,以便在事务中运行,而其余代码在事务外运行。让我们看看使用 jOOQ 事务(或TransactionTemplate)比使用@Transactional更好的场景:
@Transactional
public void fetchAndStreamWithTransactional() {
ctx.update(EMPLOYEE).set(EMPLOYEE.SALARY,
EMPLOYEE.SALARY.plus(1000)).execute();
ctx.selectFrom(EMPLOYEE)
.fetch() // jOOQ fetches the whole result set into memory
// via the connection opened by @Transactional
.stream()// stream over the in-memory result set
// (database connection is active)
.map() // ... more time-consuming pipeline operations
// holds the transaction open
.forEach(System.out::println);
}
在这种情况下,jOOQ 通过@Transactional打开的连接将整个结果集检索到内存中。这意味着流操作(例如,map())不需要事务,但 Spring 将在方法结束时关闭此事务。这可能导致长时间运行的事务。虽然我们可以通过将代码拆分为单独的方法来避免这个问题,但我们也可以依赖 jOOQ 事务(或TransactionTemplate):
public void fetchAndStreamWithJOOQTransaction() {
Result<EmployeeRecord> result =
ctx.transactionResult(configuration -> {
DSL.using(configuration).update(EMPLOYEE)
.set(EMPLOYEE.SALARY, EMPLOYEE.SALARY.plus(1000))
.execute();
return DSL.using(configuration).selectFrom(EMPLOYEE)
.fetch();
});
result.stream() // stream over the in-memory result set
// (database connection is closed)
.map() // ... more time-consuming pipeline
// operations, but the transaction is closed
.forEach(System.out::println);
}
这更好,因为我们已经从事务中移除了流操作。
在方法中执行一个或多个 DML 操作时,应使用 @Transactional 注解,显式使用 jOOQ 事务 API,或使用 TransactionTemplate 来标记事务代码。否则,Spring Boot 将报告 SQLException:连接为只读。不允许导致数据修改的查询。您可以在 SpringBootTransactional(适用于 MySQL)中的前一个示例旁边看到这样的示例。
Spring 事务的一个众所周知的问题是,如果 @Transactional 被添加到 private、protected 或包保护的类中,或者添加到与调用它的类定义相同的类的方法中,则会被忽略。默认情况下,@Transactional 仅在 public 方法上起作用,这些方法应添加到类中,并且与调用它们的类不同。然而,通过使用 jOOQ 事务 API 或 TransactionTemplate,可以轻松避免这些问题,因为它们不受这些问题的影响。您可以通过查看 JOOQTransactionNotIgnored 应用程序(适用于 MySQL)来探索一些示例。
选择 Spring 事务为我们 jOOQ 查询的一个强有力的论点是,我们可以从 Spring 事务的隔离级别和传播策略中受益。在捆绑的代码中,您可以找到一个包含七个应用程序的套件——每个应用程序对应于 Spring 事务支持的七个传播级别之一——这些应用程序展示了 jOOQ 查询和 Spring 事务传播的使用。这些应用程序被称为 Propagation{Foo},适用于 MySQL。
总结来说,jOOQ 查询可以在以下情况下使用:
-
只有在 Spring 事务中(您可以充分利用 Spring 事务的特性)
-
只有在 jOOQ 事务(在 Spring Boot 的上下文中,您将获得依赖数据库隔离级别的读写、嵌套事务)中
-
通过将它们结合使用,而不将 Spring 与 jOOQ 事务交织在一起以完成常见任务(换句话说,一旦打开 Spring 事务,确保任何后续的内层事务也是 Spring 事务。如果您打开 jOOQ 事务,那么确保任何后续的内层事务也是 jOOQ 事务。)
钩子式响应式事务
如前所述,从 jOOQ 3.17 版本开始,我们可以利用响应式事务或 R2DBC 中的事务。响应式事务可以通过 Publisher 容易地使用,因为它们与 JDBC 的阻塞 API 具有相同的语义。以下是一个编写嵌套响应式事务的示例:
Flux<?> flux = Flux.from(
ctx.transactionPublisher(outer -> Flux.from(
DSL.using(outer).delete(SALE) // or, outer.dsl()
.where(SALE.SALE_ID.eq(2L)))
.thenMany(Flux.from(
DSL.using(outer).transactionPublisher( // or, outer.dsl()
inner -> Flux.from(
DSL.using(inner).insertInto(TOKEN) // or, inner.dsl()
.set(TOKEN.SALE_ID, 1L)
.set(TOKEN.AMOUNT, 1000d)
)))
)));
flux.subscribe();
由于在 Spring Boot 中,此示例依赖于 Project Reactor (projectreactor.io/),但您可以使用任何其他响应式库。更多示例可以在捆绑的 MySQL 代码中的 jOOQReactiveTransaction 中找到。
锁定
锁定用于协调对数据的并发访问,以防止 竞争条件 线程、死锁、丢失更新 和其他 SQL 现象。
在最流行的锁定机制中,我们有乐观和悲观锁定。正如你很快就会看到的,jOOQ 支持它们在 CRUD 操作中。所以,让我们从乐观锁定开始。
乐观锁定概述
乐观锁定通常与丢失更新SQL 现象相关,所以让我们快速概述这个异常。
丢失更新是一种常见的异常,可能会严重影响数据完整性。一个事务读取一条记录并使用这些信息做出业务决策(例如,可能导致该记录被修改的决策)而没有意识到,在此期间,一个并发事务已经修改了该记录并提交了它。当第一个事务提交时,它没有意识到丢失更新。这可能会导致数据完整性问题(例如,库存可能报告负值,某些付款可能丢失,等等)。
考虑以下图中所示的场景:
![Figure 9.9 – Lost update phenomena]
![img/B16833_Figure_9.9.jpg]
图 9.9 – 丢失更新现象
如果我们逐步分析这个场景,那么以下情况会发生:
-
约翰和玛丽获取相同付款的发票金额(2,300)。
-
玛丽认为当前的发票金额太高,因此将金额从2,300更新为2,000。
-
约翰的交易没有意识到玛丽的更新。
-
约翰认为当前的发票金额不足,因此将金额更新为2,800,而没有意识到玛丽的决定。
这种异常会影响可重复读隔离级别,可以通过设置可重复读或串行化隔离级别来避免。对于没有多版本并发控制(MVCC)的可重复读隔离级别,数据库使用共享锁来拒绝其他事务尝试修改已检索的记录。
然而,在存在 MVCC 数据库的情况下,没有必要使用锁,因为我们可以使用应用级别的乐观锁定机制。通常,应用级别的乐观锁定是通过向相应的表(s)添加一个整数字段(通常命名为version)开始的。默认情况下,此字段为 0,每次UPDATE尝试将其增加 1,如图所示(这也称为版本化乐观锁定):
![Figure 9.10 – Versioned optimistic locking (via numeric field)]
![img/B16833_Figure_9.10.jpg]
图 9.10 – 基于数值字段的版本化乐观锁定
这次,约翰知道玛丽的决定,因此他可以根据这个信息做出进一步的决策。在应用级别的乐观锁定中,应用程序负责处理版本字段。应用程序应设置版本值,并将适当的WHERE子句附加到执行的UPDATE/DELETE中,以检查数据库中的版本值。此外,如果没有发生UPDATE/DELETE操作,因为WHERE version=?失败,那么应用程序负责发出此行为信号,这意味着相应的事务包含过时的数据。通常,它会通过抛出一个有意义的异常来完成。正如您接下来将看到的,jOOQ 与此行为保持一致。
对于跨越多个(HTTP)请求的长对话,除了应用级别的乐观锁定机制之外,您还必须保留旧数据快照(例如,jOOQ 可更新记录)。在 Web 应用程序中,它们可以存储在 HTTP 会话中。
jOOQ 乐观锁定
默认情况下,jOOQ 的 CRUD 操作的乐观锁定机制是禁用的。可以通过withExecuteWithOptimisticLocking()设置来启用,如下所示:
@Configuration
public class JooqSetting {
@Bean
public Settings jooqSettings() {
return new Settings()
.withExecuteWithOptimisticLocking(true);
}
}
当然,您也可以通过使用配置来在本地切换此设置。
通过 SELECT … FOR UPDATE 进行 jOOQ 乐观锁定
在这一点上,如果没有进一步的设置,jOOQ 将拦截任何 CRUD UPDATE/DELETE(通过update()/delete()显式执行或通过merge()/store()生成),并尝试确定记录是否包含过时数据。为此,jOOQ 通过SELECT … FOR UPDATE获取涉及数据的数据库独占读写锁,这实际上是通过悲观锁定完成的。接下来,jOOQ 将获取的数据与要更新/删除的数据进行比较。实际上,获取的数据与要更新/删除的记录的原始值进行比较。如果记录数据不是过时的,那么 jOOQ 将对数据库执行UPDATE/DELETE;否则,它将抛出一个特定的org.jooq.exception.DataChangedException。
例如,在更新支付金额(PAYMENT.INVOICE_AMOUNT)之前,jOOQ 将执行以下SELECT(MySQL 方言):
SELECT
`classicmodels`.`payment`.`customer_number`,
...
FROM `classicmodels`.`payment` WHERE
(`classicmodels`.`payment`.`customer_number` = ? AND
`classicmodels`.`payment`.`check_number` = ?) FOR UPDATE
启用这种类型的乐观锁定相当简单,但它有两个主要缺点:它使用独占锁,并且应用于所有 CRUD DELETE/UPDATE,这意味着它也应用于所有表。
然而,jOOQ 还支持通过 TIMESTAMP 或 VERSION 字段进行乐观锁定。这种实现更为流行,所以让我们接下来看看这个。
通过 TIMESTAMP/VERSION 字段进行 jOOQ 乐观锁定
我们已经从上一节中了解到,jOOQ 乐观锁定是通过withExecuteWithOptimisticLocking(true)标志启用的。接下来,我们必须为相应的表添加一个TIMESTAMP类型字段(用于 TIMESTAMP 乐观锁定)或INT类型字段(用于 VERSION 乐观锁定)。例如,让我们添加PAYMENT表(针对 MySQL 方言):
CREATE TABLE `payment` (
`customer_number` Bigint NOT NULL,
`check_number` Varchar(50) NOT NULL,
...
`version` INT NOT NULL DEFAULT 0,
`modified` TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT `payment_pk`
PRIMARY KEY (`customer_number`,`check_number`),
...
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
当然,您不必添加两者!决定您需要哪种类型的乐观锁,并添加相应的字段。接下来,我们必须通知 jOOQ 代码生成器这些字段应该用于乐观锁。我们可以通过编程或声明性方式做到这一点。对于 Maven 应用程序,您可以通过 <recordVersionFields/> 或 recordTimestampFields/> 分别完成此操作:
<database>
<!-- numeric column for versioned optimistic locking -->
<recordVersionFields>version</recordVersionFields>
<!-- timestamp column for versioned optimistic locking -->
<recordTimestampFields>modified</recordTimestampFields>
</database>
对于 Gradle,请查看捆绑的代码。
在这个阶段,基于 TIMESTAMP/VERSION 字段的 jOOQ 乐观锁已经准备好使用。有两个 jOOQ 标志对于控制(启用/禁用)基于 TIMESTAMP/VERSION 字段的乐观锁非常有用。这两个标志默认设置为 true(withUpdateRecordVersion() 和 withUpdateRecordTimestamp()),因此您不需要显式启用它们。然而,您可以使用它们来禁用这种类型的乐观锁。
然而,在这个阶段,有一个重要的方面您应该注意。到目前为止,jOOQ 使用基于 TIMESTAMP/VERSION 字段的乐观锁来处理任何 PaymentRecord 类型的记录,即更新/删除的记录,但它仍然使用 SELECT … FOR UPDATE 来处理执行 CRUD UPDATE/DELETE 语句的其他记录。如果您不需要这样做,那么您可以显式启用 jOOQ 标志设置(默认禁用);即,withExecuteWithOptimisticLockingExcludeUnversioned()。例如,您可以指示 jOOQ 只使用基于 TIMESTAMP/VERSION 字段的乐观锁,如下所示:
@Bean // VERSION field (numeric)
public Settings jooqSettings() {
return new Settings()
.withUpdateRecordVersion(true) // it can be omitted
.withExecuteWithOptimisticLocking(true)
.withExecuteWithOptimisticLockingExcludeUnversioned(true);
}
@Bean // TIMESTAMP field (timestamp)
public Settings jooqSettings() {
return new Settings()
.withUpdateRecordTimestamp(true) // it can be omitted
.withExecuteWithOptimisticLocking(true)
.withExecuteWithOptimisticLockingExcludeUnversioned(true);
}
因此,如果我们将这些设置组合成一个逻辑图,我们可以得到以下内容:

图 9.11 – jOOQ 乐观锁设置
如果您可以在版本和时间戳之间选择,那么选择版本。如果遗留系统使用它们,或者为了快速胜利,jOOQ 也必须支持时间戳,但使用时间戳始终存在精度不足的风险。例如,当两个更新在非常短的时间内发生时,时间戳可能仍然是相同的。这种情况不会发生在版本上。
接下来,让我们尝试将基于 VERSION 字段的 jOOQ 乐观锁应用到 StoreRecords 应用程序(在 Using merge() versus store() 部分创建的、适用于 MySQL 且使用 store() 的应用程序)。
让我们看看一些代码
我假设您已经熟悉 StoreRecords 代码,它构成了以下场景:我们必须加载并显示特定客户的付款(例如,PAYMENT.CUSTOMER_NUMBER.eq(103L))。用户应该能够通过用户界面插入该客户的新付款或更新现有付款的金额,如下面的截图所示:

图 9.12 – 插入/更新付款
在幕后,我们使用 store():
@Transactional
public int storePayment(PaymentRecord pr) {
ctx.attach(pr);
return pr.store();
}
在这里,如果有两个并发事务更新相同的付款,那么我们的代码容易受到丢失更新异常的影响,因此我们必须进行乐观锁定。
到目前为止,我们已经在PAYMENT中添加了version字段:
CREATE TABLE `payment` (
...
`version` INT NOT NULL DEFAULT 0,
...
}
我们还添加了基于 VERSION 字段启用 jOOQ 乐观锁定的设置,因此我们设置了以下内容:
<database>
<recordVersionFields>version</recordVersionFields>
</database>
我们还设置了以下内容:
@Bean
public Settings jooqSettings() {
return new Settings()
.withExecuteWithOptimisticLocking(true)
.withExecuteWithOptimisticLockingExcludeUnversioned(true);
}
到目前为止,一切顺利!从乐观锁定的角度来看,有趣的部分开始于我们调用store()方法时。如果我们尝试存储一个新的PaymentRecord,那么store()将生成一个不受乐观锁定影响的INSERT语句。然而,如果这个PaymentRecord需要更新,那么乐观锁定将丰富生成的UPDATE(同样适用于DELETE)的WHERE子句,以显式检查version号,如下面的 MySQL UPDATE所示:
UPDATE
`classicmodels`.`payment`
SET
`classicmodels`.`payment`.`invoice_amount` = ?,
`classicmodels`.`payment`.`version` = ?
WHERE
(
`classicmodels`.`payment`.`customer_number` = ?
and `classicmodels`.`payment`.`check_number` = ?
and `classicmodels`.`payment`.`version` = ?
)
如果数据库中的version号与WHERE子句中的版本号不匹配,那么这个记录包含过时的数据(另一个事务已修改此数据)。这将导致 jOOQ DataChangedException,可以在我们的控制器端点中处理,如下所示:
@PostMapping("/store")
public String storePayment(SessionStatus sessionStatus,
RedirectAttributes redirectAttributes,
@ModelAttribute(PAYMENT_ATTR) PaymentRecord pr,
BindingResult bindingResult) {
if (!bindingResult.hasErrors()) {
try {
classicModelsService.storePayment(pr);
sessionStatus.setComplete();
} catch (org.jooq.exception.DataChangedException e) {
bindingResult.reject("",
"Another user updated the data.");
}
}
if (bindingResult.hasErrors()) {
redirectAttributes.addFlashAttribute(
BINDING_RESULT, bindingResult);
}
return "redirect:payments";
}
因此,如果发生DataChangedException,那么我们必须在BindingResult中添加一个全局错误,包含消息另一个用户更新了数据。这个消息将通过 Thymeleaf 渲染在标签为刷新的按钮旁边,如下面的截图所示:


图 9.13 – 向用户发出过时数据的信号
记得 jOOQ 的refresh()方法吗?这是突出其可用性的完美地方,因为用户应该看到记录的最新状态。这正是refresh()的工作。在这种情况下,reset()方法没有帮助,因为reset()会将记录恢复到内存中的原始值,这是另一回事。所以,让我们使用refresh()来执行一个SELECT,这将获取这个PaymentRecord的最新状态:
@GetMapping(value = "/refresh")
public String refreshPayment(Model model) {
if (model.containsAttribute(PAYMENT_ATTR)) {
classicModelsService.refreshPayment(
(PaymentRecord) model.getAttribute(PAYMENT_ATTR));
}
return "redirect:payments";
}
public void refreshPayment(PaymentRecord pr) {
pr.refresh();
}
刷新后,用户会看到由并发事务提前更新的数据,并可以决定是否继续他们的更新。要重现此场景,请按照以下步骤操作:
-
启动两个浏览器(主要是两个用户或 HTTP 会话)。
-
在两者中,使用Load链接获取相同的付款。
-
对于用户 A,更新发票金额并点击完成。这应该会成功更新付款。
-
对于用户 B,更新发票金额并点击完成。由于用户 A 已经更新了这笔付款,你应该会看到前面截图中的消息。
-
点击刷新。现在,你应该会看到用户 A 设置的发票金额。
-
对于用户 B,再次尝试更新。这次,它将按预期工作。
总结来说,如果调用store()产生了显式的UPDATE/DELETE或UPDATE,jOOQ 版本/时间戳乐观锁定将丰富生成的UPDATE/DELETE的WHERE子句,以显式检查时间戳字段的数值。在调用merge()的情况下,将根据记录中是否存在数值/时间戳值生成显式的INSERT或UPDATE。
这个示例的完整代码可以在OLVersionStoreRecords中找到。使用 TIMESTAMP 字段的替代方案可以在OLTimestampStoreRecords中找到。最后,SELECT … FOR UPDATE解决方案可以在OLPLStoreRecords中找到。所有这些都在 MySQL 中可用。
重试失败的事务
让我们考虑我们的场景被更新。如果一个事务没有使用比当前金额更大的发票金额更新付款,那么这个事务应该在没有用户交互的情况下重试(因此,在这种情况下,我们不在乎丢失的更新)。否则,用户应该看到当前金额并从界面执行更新(由于刷新应该自动完成,所以将没有刷新按钮)。
但我们如何在应用程序中重试一个失败的事务?在 Spring Boot 中,这相当于再次执行失败的@Transactional storePayment(PaymentRecord pr)方法,这可以通过 Spring Retry 来完成。一旦你添加了 Spring Retry(请参阅捆绑的代码),你必须调整storePayment(PaymentRecord pr)方法,如下所示:
@Transactional
@Retryable(
value = org.jooq.exception.DataChangedException.class,
maxAttempts = 2, backoff = @Backoff(delay = 100))
public int storePayment(PaymentRecord pr) {
int stored = 0;
try {
ctx.attach(pr);
stored = pr.store();
} catch (org.jooq.exception.DataChangedException e) {
BigDecimal invoiceAmount = pr.getInvoiceAmount();
pr.refresh();
if (invoiceAmount.doubleValue() >
pr.getInvoiceAmount().doubleValue()) {
pr.setInvoiceAmount(invoiceAmount);
throw e;
}
throw new OptimisticLockingRetryFailed(e.getMessage());
}
return stored;
}
因此,这次,我们捕获DataChangedException并分析发票金额的当前值与刷新的记录(数据库的最新状态)进行比较。如果当前金额大于获取的金额,则我们将它设置为获取金额的替代,并抛出捕获的DataChangedException。这将触发 Spring Retry 机制,应该重试这个事务。否则,我们必须抛出一个自定义的OptimisticLockingRetryFailed异常,这将导致用户看到显式消息。你可以在OLRetryVersionStoreRecords(适用于 MySQL)中练习这个示例。
悲观锁定概述
悲观锁定是通过排他性/共享锁锁定行(或单元格)直到获取这些锁的事务完成其任务。根据锁的强度,其他事务可能只能部分地与这些行/单元格交互,或者它们将不得不中止或等待资源可用(无锁)。从众所周知的SELECT … FOR UPDATE(针对行的排他性读写锁(记录锁))和SELECT … FOR UPDATE OF(针对 Oracle 特定单元格的排他性读写锁)到SELECT … FOR UPDATE NOWAIT和SELECT … FOR UPDATE WAIT n(也特定于 Oracle),再到更宽松的SELECT ... FOR UPDATE SKIP LOCKED、SELECT … FOR SHARE以及 PostgreSQL 特定的SELECT … FOR NO KEY UPDATE和SELECT … FOR KEY SHARE,jOOQ 都支持它们。
jOOQ 悲观锁定
正如我们在悲观锁定概述部分中提到的,jOOQ 支持大量的SELECT … FOR FOO类型的锁。例如,我们可以通过forUpdate()显式调用SELECT … FOR UPDATE:
ctx.selectFrom(PRODUCTLINE)
.where(PRODUCTLINE.PRODUCT_LINE.eq("Classic Cars"))
.forUpdate()
.fetchSingle();
如果事务 A 执行此语句,则锁定相应的行。其他事务,事务 B,必须在事务 A 释放此排他锁之前,才能在相同资源上执行其任务。查看ForUpdate应用程序中的此场景(适用于 MySQL)- 注意,此应用程序会导致异常:MySQLTransactionRollbackException:锁等待超时;尝试重新启动事务。另外,查看ForUpdateForeignKey(适用于 PostgreSQL)- 此示例突出了FOR UPDATE对外键的影响,这是由于此锁不仅影响当前表的行,还影响其他表的引用行。
因此,继续在同一上下文中,SELECT … FOR UPDATE锁定所有涉及表(在FROM子句中列出,连接等)中选定的行。如果表 X 和表 Y 在这种情况下涉及,则SELECT … FOR UPDATE锁定两个表的行,即使事务 A 仅影响表 X 的行。另一方面,事务 B 需要从表 Y 获取锁,但它不能这样做,直到事务 A 释放对表 X 和 Y 的锁。
对于此类场景,Oracle 有SELECT … FOR UPDATE OF,这允许我们指定应该被锁定的列。在这种情况下,Oracle 仅锁定具有列名列在FOR UPDATE OF中的表(的)行。例如,以下语句仅锁定PRODUCTLINE的行,即使PRODUCT表也涉及在内:
ctx.select(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.CODE,
PRODUCT.PRODUCT_LINE, PRODUCT.PRODUCT_NAME,
PRODUCT.PRODUCT_SCALE)
.from(PRODUCTLINE).join(PRODUCT).onKey()
// lock only rows from PRODUCTLINE
.forUpdate().of(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.CODE)
.fetch();
由于PRODUCT表未锁定,另一个语句可以锁定其行:
ctx.select(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.CODE,
PRODUCT.PRODUCT_LINE, PRODUCT.PRODUCT_NAME,
PRODUCT.PRODUCT_SCALE)
.from(PRODUCTLINE).join(PRODUCT).onKey()
// lock only rows from PRODUCT
.forUpdate().of(PRODUCT.PRODUCT_NAME)
.fetch();
如果我们移除.of(PRODUCT.PRODUCT_NAME),则此语句将尝试锁定PRODUCTLINE的行,因此它将不得不等待该表的锁释放。您可以通过访问ForUpdateOf应用程序(适用于 Oracle)来查看此示例。
如果事务要获取锁或立即失败,则应使用SELECT … FOR UPDATE NOWAIT:
ctx.selectFrom(PRODUCT)
.forUpdate()
.noWait() // acquire the lock or fails immediately
.fetch();
然而,如果事务需要等待固定的时间,那么我们必须依赖于SELECT … FOR UPDATE WAIT n锁(Oracle),其中n是等待时间,以秒为单位:
ctx.selectFrom(PRODUCT)
.forUpdate()
.wait(15)
.fetch();
您可以在ForUpdateWait(适用于 Oracle)中查看一个示例。您将看到,事务 A 立即获取锁,而事务 B 在获取相同资源的锁之前等待一段时间。如果在事务 A 释放锁之前此时间已过期,则您将收到一个错误,表明ORA-30006:资源忙;超时等待获取。
让我们考虑以下场景:为了提供高质量的产品描述,我们有审查员分析每个产品并编写适当的描述。由于这是PRODUCT表上的并发过程,挑战在于协调审查员,以确保他们不会同时审查相同的产品。为了选择一个产品进行审查,审查员应该跳过已经审查过的产品(PRODUCT.PRODUCT_DESCRIPTION.eq("PENDING"))和当前正在审查的产品。这就是我们所说的基于并发表的队列(也称为作业队列或批量队列)。
这是一个SKIP LOCKED的工作。这个 SQL 选项在许多数据库中可用(Oracle、MySQL 8、PostgreSQL 9.5 等),并指示数据库跳过已锁定的行,并锁定之前未锁定的行:
Result<ProductRecord> products = ctx.selectFrom(PRODUCT)
.where(PRODUCT.PRODUCT_DESCRIPTION.eq("PENDING"))
.orderBy(PRODUCT.PRODUCT_ID).limit(3)
.forUpdate()
.skipLocked()
.fetch();
如果事务 A 执行这个语句,那么它可能会锁定 ID 为1、2和3的PENDING产品。当事务 A 持有这个锁时,事务 B 执行相同的语句并将锁定 ID 为4、5和6的PENDING产品。你可以在ForUpdateSkipLocked(适用于 MySQL)中看到这个场景。
SELECT … FOR UPDATE的一种较弱的形式是SELECT … FOR SHARE查询。这确保在插入父记录的子记录时保持引用完整性。例如,事务 A 执行以下操作:
SaleRecord sr = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(2L))
.forShare()
.fetchSingle();
ctx.insertInto(TOKEN)
.set(TOKEN.SALE_ID, sr.getSaleId())
.set(TOKEN.AMOUNT, 1200.5)
.execute();
然而,如果事务 A 持有SHARE锁,事务 B 不能UPDATE:
ctx.update(SALE)
.set(SALE.SALE_, SALE.SALE_.plus(1000))
.where(SALE.SALE_ID.eq(2L))
.execute();
此外,事务 C 不能DELETE:
ctx.delete(SALE)
.where(SALE.SALE_ID.eq(2L))
.execute();
你可以在ForShare(适用于 PostgreSQL)中查看这个示例。
从版本 9.3 开始,PostgreSQL 支持两个额外的锁定子句:SELECT … FOR NO KEY UPDATE和SELECT … FOR KEY SHARE。前者与FOR UPDATE锁定子句的作用类似,但它不会阻塞SELECT … FOR KEY SHARE。例如,事务 A 使用SELECT … FOR NO KEY UPDATE:
SaleRecord sr = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(2L))
.forNoKeyUpdate()
.fetchSingle();
ctx.insertInto(TOKEN)
.set(TOKEN.SALE_ID, sr.getSaleId())
.set(TOKEN.AMOUNT, 1200.5)
.execute();
即使事务 A 持有这个资源的锁,事务 B 也可以获取SELECT … FOR KEY SHARE:
ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(2L))
.forKeyShare()
.fetchSingle();
然而,如果事务 A 没有释放其锁,事务 C 不能在这个资源上获取SELECT … FOR SHARE:
ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(2L))
.forShare()
.fetchSingle();
你可以在ForNoKeyUpdate(适用于 PostgreSQL)中查看这个示例。
最后,SELECT … FOR KEY SHARE是最弱的锁。例如,事务 A 获取以下类型的锁:
SaleRecord sr = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(2L))
.forKeyShare()
.fetchSingle();
ctx.insertInto(TOKEN)
.set(TOKEN.SALE_ID, sr.getSaleId())
.set(TOKEN.AMOUNT, 1200.5)
.execute();
当事务 A 持有这个锁时,如果事务 B 不尝试更新SALE_ID,它可以执行更新操作:
ctx.update(SALE)
.set(SALE.SALE_, SALE.SALE_.plus(1000))
.where(SALE.SALE_ID.eq(2L))
.execute();
事务 B 将不得不等待事务 A 释放锁,因为它试图更新SALE_ID:
ctx.update(SALE)
.set(SALE.SALE_ID, SALE.SALE_ID.plus(50))
.where(SALE.SALE_ID.eq(2L))
.execute();
最后,如果事务 A 持有KEY SHARE锁,事务 C 不能DELETE:
ctx.delete(SALE)
.where(SALE.SALE_ID.eq(2L))
.execute();
你可以在ForNoKeyUpdate(适用于 PostgreSQL)中查看这个示例。
死锁
死锁不仅限于数据库 – 它可以发生在任何涉及并发环境(并发控制)的场景中,并且它们主要定义了两个进程无法前进的情况,因为它们正在等待对方完成(释放锁)。在数据库的情况下,一个经典的死锁可以表示如下:

图 9.14 – 死锁的经典案例
在这里,我们有两个不使用显式锁的事务(数据库本身依赖于锁,因为它会检测事务是否尝试修改数据)。事务 A 已经获取了SALE资源的锁,并且它不会释放它,直到它设法获取ORDER资源的另一个锁,而该锁目前被事务 B 锁定。同时,事务 B 持有ORDER资源的锁,并且它不会释放它,直到它设法获取被事务 A 锁定的SALE资源的锁。你可以在这个场景中看到Deadlock(适用于 MySQL)的示例。
然而,使用显式锁并不意味着死锁就不会发生。例如,在DeadlockShare(适用于 MySQL)中,你可以看到显式使用SELECT … FOR SHARE导致死锁的情况。理解每种类型的锁做什么以及当存在某种锁时允许的其他锁(如果有的话)非常重要。以下表格涵盖了常见的锁:

图 9.15 – 锁定获取权限
数据库会自动扫描事务以发现死锁(或所谓的锁等待循环)。当发生死锁时,数据库会尝试通过终止其中一个事务来修复它。这会释放锁,并允许其他事务继续进行。在这种情况下,始终依赖NOWAIT或显式的短超时来避免死锁。虽然数据库可以在死锁后恢复,但它只能在超时(如果有的话)之后才能这样做。因此,长超时意味着数据库连接长时间忙碌,这会带来性能惩罚。此外,锁定过多数据可能会影响可伸缩性。
摘要
我很高兴你走到了这一步,并且我们已经成功覆盖了本章的三个主要主题 – CRUD、事务和锁定。到这一点,你应该熟悉 jOOQ 的可更新记录以及它们在 CRUD 操作中的工作方式。除此之外,我们还学习了诸如必须知道的attach()/detach()、方便的original()和reset(),以及花哨的store()和refresh()操作。在那之后,我们学习了如何在 Spring Boot 和 jOOQ API 的上下文中处理事务,然后解决了 jOOQ 中的乐观锁和悲观锁。
在下一章中,我们将学习如何通过 jOOQ 将批处理、批量加载数据文件到数据库。我们还将进行单线程和多线程批处理。
第十章:导出、分批、批量加载数据
处理大量数据需要具备在导出、分批、批量加载数据方面的专业技能(知识和技术以及编程技能)。这些领域中的每一个都需要大量的代码和大量时间来实现和测试。幸运的是,jOOQ 提供了涵盖所有这些操作的全面 API,并以流畅的风格公开它们,同时隐藏了实现细节。在这种情况下,我们的议程包括以下内容:
-
以文本、JSON、XML、CSV、图表和
INSERT语句导出数据 -
分批
INSERT、UPDATE、DELETE、MERGE和Record -
批量查询
-
加载 JSON、CSV、数组以及
Record
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter10。
导出数据
通过org.jooq.Formattable API 可以导出(或格式化)数据。jOOQ 公开了一系列format()和formatFoo()方法,可用于将Result和Cursor(记住来自第八章,获取和映射中的fetchLazy())格式化为文本、JSON、XML、CSV、XML、图表和INSERT语句。正如您在文档中所见,所有这些方法都有不同的变体,可以将数据导出到字符串或文件中,通过 Java 的OutputStream或Writer API。
以文本格式导出
我相信您已经在控制台输出中看到了类似以下内容:

图 10.1 – 表格文本数据
这种文本表格表示可以通过format()方法实现。此方法的一种变体接受一个整数参数,表示要包含在格式化结果中的最大记录数(默认情况下,jOOQ 仅记录通过 jOOQ 文本导出格式化的结果的前五条记录,但我们可以轻松地格式化和记录所有fetch记录,如result.format(result.size()))。但是,如果您需要调整此输出,那么 jOOQ 有一个名为TXTFormat的专用不可变类,在文档中提供了许多直观的选项。使用此类并结合通过format(Writer writer, TXTFormat format)将结果文本导出到名为result.txt`的文件中,可以像以下示例所示进行:
try (BufferedWriter bw = Files.newBufferedWriter(
Paths.get("result.txt"), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
ctx.select(PRODUCTLINE.PRODUCT_LINE, PRODUCT.PRODUCT_ID,
PRODUCT.PRODUCT_NAME)
.from(PRODUCTLINE)
.join(PRODUCT).onKey()
.fetch()
.format(bw, new TXTFormat().maxRows(25).minColWidth(20));
} catch (IOException ex) { // handle exception }
您可以在捆绑的代码中看到此示例,Format(适用于 MySQL 和 PostgreSQL),在其它示例旁边。
导出 JSON
通过formatJSON()及其重载可以将Result/Cursor导出为 JSON。如果没有参数,formatJSON()生成包含两个主要数组的 JSON:一个名为"fields"的数组,表示标题(如您稍后所见,这可以用于将 JSON 导入数据库),以及一个名为"records"的数组,它包装了获取的数据。以下是一个这样的输出:
{
"fields": [
{"schema": "public", "table": "productline", "name":
"product_line", "type": "VARCHAR"},
{"schema": "public", "table": "product", "name":
"product_id", "type": "BIGINT"},
{"schema": "public", "table": "product", "name":
"product_name", "type": "VARCHAR"}
],
"records": [
["Vintage Cars", 80, "1936 Mercedes Benz 500k Roadster"],
["Vintage Cars", 29, "1932 Model A Ford J-Coupe"],
...
]
}
因此,可以通过不带参数的 formatJSON() 方法或通过 formatJSON(JSONFormat.DEFAULT_FOR_RESULTS) 获取此 JSON。如果我们只想渲染 "records" 数组并避免渲染由 "fields" 数组表示的标题,则可以依赖 formatJSON(JSONFormat.DEFAULT_FOR_RECORDS)。这会产生如下所示的内容(您稍后会发现,这也可以导入回数据库):
[
["Vintage Cars", 80, "1936 Mercedes Benz 500k Roadster"],
["Vintage Cars", 29, "1932 Model A Ford J-Coupe"],
...
]
DEFAULT_FOR_RESULTS 和 DEFAULT_FOR_RECORDS 是不可变的 org.jooq.JSONFormat 的两个静态,用于微调 JSON 的导入/导出。当这些静态不足以满足需求时,我们可以实例化 JSONFormat 并流畅地添加一系列直观的选项,例如本示例中的选项(请参阅 jOOQ 文档中的所有可用选项):
JSONFormat jsonFormat = new JSONFormat()
.indent(4) // defaults to 2
.header(false) // default to true
.newline("\r") // "\n" is default
.recordFormat(
JSONFormat.RecordFormat.OBJECT); // defaults to ARRAY
此外,让我们在将 JSON 导出到文件时使用 formatJSON(Writer writer, JSONFormat format) 的上下文中使用 jsonFormat:
try ( BufferedWriter bw = Files.newBufferedWriter(
Paths.get("resultObject.json"), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
ctx.select(PRODUCTLINE.PRODUCT_LINE, PRODUCT.PRODUCT_ID,
PRODUCT.PRODUCT_NAME)
.from(PRODUCTLINE)
.join(PRODUCT).onKey()
.fetch()
.formatJSON(bw, jsonFormat);
} catch (IOException ex) { // handle exception }
生成的 JSON 看起来如下(也可以导入到数据库中):
[
{
"product_line": "Vintage Cars",
"product_id": 80,
"product_name": "1936 Mercedes Benz 500k Roadster"
},
…
]
如果我们获取单个 Record(因此,不是 Result/Cursor,例如通过 fetchAny()),那么 formatJSON() 将返回一个只包含数据的数组,如下是获取 Record3<String, Long, String> 的示例:
["Classic Cars",2,"1952 Alpine Renault 1300"]
但是,如果我们明确指出 JSONFormat.RecordFormat.OBJECT,则它变为以下内容:
{"product_line":"Classic Cars","product_id":2,
"product_name":"1952 Alpine Renault 1300"}
您可以在捆绑的代码中的 Format 示例中查看此示例(适用于 MySQL 和 PostgreSQL),包括格式化 UDT、数组类型和可嵌入类型作为 JSON 的其他示例。
导出 XML
将 Result/Cursor 作为 XML 导出可以通过 formatXML() 和其重载方法完成。不带参数时,formatXML() 生成包含两个主要元素的 XML:一个名为 <fields/> 的元素,表示标题,以及一个名为 <records/> 的元素,它包装了获取的数据。以下是一个输出示例:
<result xmlns="http:...">
<fields>
<field schema="public" table="productline"
name="product_line" type="VARCHAR"/>
<field schema="public" table="product"
name="product_id" type="BIGINT"/>
<field schema="public" table="product"
name="product_name" type="VARCHAR"/>
</fields>
<records>
<record xmlns="http:...">
<value field="product_line">Vintage Cars</value>
<value field="product_id">80</value>
<value field="product_name">1936 Mercedes Benz ...</value>
</record>
...
</records>
</result>
生成此输出的 jOOQ 代码如下:
ctx.select(PRODUCTLINE.PRODUCT_LINE,
PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_NAME)
.from(PRODUCTLINE)
.join(PRODUCT).onKey()
.fetch()
.formatXML();
因此,可以通过不带参数的 formatXML() 方法或通过 formatXML(XMLFormat.DEFAULT_FOR_RESULTS) 获取此 XML。如果我们只想保留 <records/> 元素并避免渲染 <fields/> 元素,则使用 formatJXML(XMLFormat.DEFAULT_FOR_RECORDS)。这是一个输出示例:
<result>
<record>
<value field="product_line">Vintage Cars</value>
<value field="product_id">80</value>
<value field="product_name">1936 Mercedes Benz ...</value>
</record>
...
</result>
DEFAULT_FOR_RESULTS 和 DEFAULT_FOR_RECORDS 是不可变的 org.jooq.XMLFormat 的两个静态,用于微调 XML 的导入/导出。除了这些之外,我们还可以实例化 XMLFormat 并流畅地添加一系列直观的选项。例如,前面的 XML 片段是基于默认记录格式 XMLFormat.RecordFormat.VALUE_ELEMENTS_WITH_FIELD_ATTRIBUTE 生成的,请注意 <value/> 元素和 field 属性。但是,使用 XMLFormat,我们可以选择两种其他选项:VALUE_ELEMENTS 和 COLUMN_NAME_ELEMENTS。前者使用 <value/> 元素格式化记录,如下所示:
<record xmlns="http:...">
<value>Vintage Cars</value>
<value>29</value>
<value>1932 Model A Ford J-Coupe</value>
</record>
COLUMN_NAME_ELEMENTS 使用列名作为元素。让我们使用此设置与 header(false) 一起格式化 MANAGER.MANAGER_EVALUATION UDT(在 PostgreSQL 模式中可用):
ctx.select(MANAGER.MANAGER_ID, MANAGER.MANAGER_EVALUATION)
.from(MANAGER)
.fetch()
.formatXML(new XMLFormat()
.header(false)
.recordFormat(XMLFormat.RecordFormat.COLUMN_NAME_ELEMENTS))
生成的 XML 如下:
<record xmlns="http...">
<manager_id>1</manager_id>
<manager_evaluation>
<record xmlns="http...">
<communication_ability>67</communication_ability>
<ethics>34</ethics>
<performance>33</performance>
<employee_input>66</employee_input>
</record>
</manager_evaluation>
</record>
如果我们获取单个Record(因此,没有通过fetchAny()等获取Result/Cursor),那么formatXML()将返回只包含数据的 XML,如下面这个获取Record3<String, Long, String>的示例:
<record>
<value field="product_line">Classic Cars</value>
<value field="product_id">2</value>
<value field="product_name">1952 Alpine Renault 1300</value>
</record>
当然,您可以通过XMLFormat来更改此默认输出。例如,让我们考虑我们有一个这样的记录:
<Record3<String, Long, String> oneResult = …;
然后,让我们应用RecordFormat.COLUMN_NAME_ELEMENTS:
String xml = oneResult.formatXML(new XMLFormat().recordFormat(
XMLFormat.RecordFormat.COLUMN_NAME_ELEMENTS));
生成的 XML 如下:
<record xmlns="http://...">
<product_line>Classic Cars</product_line>
<product_id>2</product_id>
<product_name>1952 Alpine Renault 1300</product_name>
</record>
在捆绑的代码中,Format(适用于 MySQL 和 PostgreSQL)的其他示例中,考虑此示例(包括将 XML 导出到文件)。
导出 HTML
将Result/Cursor导出为 HTML 可以通过formatHTML()及其重载来实现。默认情况下,jOOQ 尝试将获取的数据包装在一个简单的 HTML 表中,因此,在生成的 HTML 中,您可能会看到<table/>,<th/>和<td/>等标签。例如,格式化MANAGER.MANAGER_EVALUATION UDT(在 PostgreSQL 模式中可用)可以如下进行:
ctx.select(MANAGER.MANAGER_NAME, MANAGER.MANAGER_EVALUATION)
.from(MANAGER)
.fetch()
.formatHTML();
生成的 HTML 如下:
<table>
<thead>
<tr>
<th>manager_name</th>
<th>manager_evaluation</th>
</tr>
</thead>
<tbody>
<tr>
<td>Joana Nimar</td>
<td>(67, 34, 33, 66)</td>
</tr>
...
注意到MANAGER_EVALUATION的值(67, 34, 33, 66)被包裹在一个<td/>标签中。但也许您想要得到如下这样的结果:
<h1>Joana Nimar</h1>
<table>
<thead>
<tr>
<th>communication_ability</th>
<th>ethics</th>
<th>performance</th>
<th>employee_input</th>
</tr>
</thead>
<tbody>
<tr>
<td>67</td>
<td>34</td>
<td>33</td>
<td>66</td>
</tr>
</tbody>
</table>
我们可以通过以下方式装饰我们的查询来获取此 HTML:
ctx.select(MANAGER.MANAGER_NAME, MANAGER.MANAGER_EVALUATION)
.from(MANAGER)
.fetch()
.stream()
.map(e -> "<h1>".concat(e.value1().concat("</h1>"))
.concat(e.value2().formatHTML()))
.collect(joining("<br />"))
在捆绑的代码中查看更多示例,格式(适用于 MySQL 和 PostgreSQL)。
导出 CSV
将Result/Cursor导出为 CSV 可以通过formatCSV()及其重载来实现。默认情况下,jOOQ 将 CSV 文件渲染成如下所示:
city,country,dep_id,dep_name
Bucharest,"","",""
Campina,Romania,3,Accounting
Campina,Romania,14,IT
…
在便捷的重载方法中,我们有formatCSV(布尔头部,字符分隔符,字符串 nullString)。通过此方法,我们可以指定是否渲染 CSV 头部(默认为true),记录的分隔符(默认为一个逗号),以及表示NULL值的字符串(默认为"")。紧邻此方法,我们还有一系列这些参数的组合,例如formatCSV(char delimiter, String nullString),formatCSV(char delimiter),和formatCSV(boolean header, char delimiter)。以下是一个示例,它渲染了头部(默认)并使用TAB作为分隔符,以及"N/A"来表示NULL值:
ctx.select(OFFICE.CITY, OFFICE.COUNTRY,
DEPARTMENT.DEPARTMENT_ID.as("dep_id"),
DEPARTMENT.NAME.as("dep_name"))
.from(OFFICE).leftJoin(DEPARTMENT).onKey().fetch()
.formatCSV('\t', "N/A");
生成的 CSV 看起来如下:
City country dep_id dep_name
Bucharest N/A N/A N/A
Campina Romania 3 Accounting
…
Hamburg Germany N/A N/A
London UK N/A N/A
NYC USA 4 Finance
...
Paris France 2 Sales
当我们需要更多选项时,我们可以依赖不可变的CSVFormat。以下是一个使用CSVFormat并导出结果的文件示例:
try (BufferedWriter bw = Files.newBufferedWriter(
Paths.get("result.csv"), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
ctx.select(OFFICE.CITY, OFFICE.COUNTRY,
DEPARTMENT.DEPARTMENT_ID, DEPARTMENT.NAME)
.from(OFFICE).leftJoin(DEPARTMENT).onKey()
.fetch()
.formatCSV(bw, new CSVFormat()
.delimiter("|").nullString("{null}"));
} catch (IOException ex) { // handle exception }
完整的代码在其他示例旁边可用,在捆绑的代码中,格式(适用于 MySQL 和 PostgreSQL)。
导出图表
将Result/Cursor导出为图表可能会导致如图所示的观察结果:

图 10.2 – jOOQ 图表示例
这是一个包含三个图表的区域图表:a,b和c。图表a表示PRODUCT.BUY_PRICE,图表b表示PRODUCT.MSRP,图表c表示avg(ORDERDETAIL.PRICE_EACH)。虽然此图表可以在控制台上显示,但它可以像下面这样导出到文件:
try (BufferedWriter bw = Files.newBufferedWriter(
Paths.get("result2Chart.txt"), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
ctx.select(PRODUCT.PRODUCT_ID, PRODUCT.BUY_PRICE,
field("avg_price"), PRODUCT.MSRP)
.from(PRODUCT, lateral(select(
avg(ORDERDETAIL.PRICE_EACH).as("avg_price"))
.from(ORDERDETAIL)
.where(PRODUCT.PRODUCT_ID.eq(ORDERDETAIL.PRODUCT_ID))))
.limit(5).fetch()
.formatChart(bw, cf);
} catch (IOException ex) { // handle exception }
显然,图表是通过 formatChart() 方法获得的。更确切地说,在这个示例中,通过 formatChart``(Writer writer, ChartFormat format)。ChartFormat 类是不可变的,并包含一系列用于自定义图表的选项。虽然您可以在 jOOQ 文档中检查它们的所有内容,但以下是在本例中使用的 cf:
DecimalFormat decimalFormat = new DecimalFormat("#.#");
ChartFormat cf = new ChartFormat()
.showLegends(true, true) // show legends
.display(ChartFormat.Display.DEFAULT) // or,
// HUNDRED_PERCENT_STACKED
.categoryAsText(true) // category as text
.type(ChartFormat.Type.AREA) // area chart type
.shades('a', 'b', 'c') // shades of PRODUCT.BUY_PRICE,
// PRODUCT.MSRP,
// avg(ORDERDETAIL.PRICE_EACH)
.values(1, 2, 3) // value source column numbers
.numericFormat(decimalFormat);// numeric format
在其他示例旁边的完整代码可在名为 Format 的应用程序捆绑的代码中找到(适用于 MySQL 和 PostgreSQL)。
导出 INSERT 语句
jOOQ 可以通过 formatInsert() 方法及其重载将 Result/Cursor 导出为 INSERT 语句。默认情况下,如果第一条记录是 TableRecord,则 formatInsert() 使用第一条记录的 TableRecord.getTable() 方法生成此表的 INSERT 语句;否则,它将生成 UNKNOWN_TABLE 的 INSERT 语句。在两种情况下,jOOQ 都调用 Result.fields() 方法来确定列名。
以下是一个示例,将生成的 INSERT 语句导出到磁盘上的文件。INSERT 语句生成到名为 product_stats 的数据库表中,该表通过 formatInsert(Writer writer, Table<?> table, Field<?>… fields) 指定:
try (BufferedWriter bw = Files.newBufferedWriter(
Paths.get("resultInserts.txt"), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
ctx.select(PRODUCT.PRODUCT_ID, PRODUCT.BUY_PRICE,
field("avg_price"), PRODUCT.MSRP)
.from(PRODUCT, lateral(select(
avg(ORDERDETAIL.PRICE_EACH).as("avg_price"))
.from(ORDERDETAIL)
.where(PRODUCT.PRODUCT_ID.eq(ORDERDETAIL
.PRODUCT_ID))))
.limit(5)
.fetch()
.formatInsert(bw, table("product_stats"));
} catch (IOException ex) { // handle exception }
生成的 INSERT 语句看起来如下所示:
INSERT INTO product_stats VALUES (29, 108.06, 114.23, 127.13);
在其他示例旁边完整的代码,包括导出 UDT、JSON、数组和可嵌入类型的 INSERT 语句,可在捆绑的代码 Format 中找到(适用于 MySQL 和 PostgreSQL)。接下来,让我们谈谈批处理。
批处理
批处理可以是避免由大量单独的数据库/网络往返(表示插入、删除、更新、合并等)引起的性能惩罚的完美解决方案。例如,如果没有批处理,1000 个插入需要 1000 个单独的往返,而使用批处理大小为 30 的批处理将导致 34 个单独的往返。我们拥有的插入(语句)越多,批处理就越有用。
通过 DSLContext.batch() 批处理
DSLContext 类公开了一系列 batch() 方法,允许我们在批处理模式下执行一组查询。因此,我们有以下 batch() 方法:
BatchBindStep batch(String sql)
BatchBindStep batch(Query query)
Batch batch(String... queries)
Batch batch(Query... queries)
Batch batch(Queries queries)
Batch batch(Collection<? extends Query> queries)
Batch batch(String sql, Object[]... bindings)
Batch batch(Query query, Object[]... bindings)
在幕后,jOOQ 通过 JDBC 的 addBatch() 实现这些方法。每个查询通过 addBatch() 累积到批处理中,最后它调用 JDBC 的 executeBatch() 方法将批处理发送到数据库。
例如,假设我们需要将一组 INSERT 语句批处理到 SALE 表中。如果您有 Hibernate(JPA)背景,那么您知道这种批处理将不起作用,因为 SALE 表有一个自动增长的键,Hibernate 将自动禁用/阻止插入批处理。但是,jOOQ 没有这样的问题,因此可以通过 batch(Query... queries) 将一组插入批处理到具有自动增长键的表中,如下所示:
int[] result = ctx.batch(
ctx.insertInto(SALE, SALE.FISCAL_YEAR, SALE.EMPLOYEE_NUMBER,
SALE.SALE_, SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values(2005, 1370L, 1282.64, 1, 0.0),
ctx.insertInto(SALE, SALE.FISCAL_YEAR, SALE.EMPLOYEE_NUMBER,
SALE.SALE_, SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values(2004, 1370L, 3938.24, 1, 0.0),
...
).execute();
返回的数组包含每个INSERT语句影响的行数(在这种情况下,[1, 1, 1, …])。虽然可以像您刚才看到的那样执行不带绑定值的多个查询,但 jOOQ 允许我们多次执行一个查询,并带有绑定值,如下所示:
int[] result = ctx.batch(
ctx.insertInto(SALE, SALE.FISCAL_YEAR,SALE.EMPLOYEE_NUMBER,
SALE.SALE_, SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values((Integer) null, null, null, null, null))
.bind(2005, 1370L, 1282.64, 1, 0.0)
.bind(2004, 1370L, 3938.24, 1, 0.0)
...
.execute();
注意,您将不得不为原始查询提供虚拟的绑定值,这通常通过null值来实现,如本例所示。jOOQ 生成一个单独的查询(PreparedStatement),其中包含占位符(?),并将循环绑定值以填充批次。每当您看到int[]包含负值(例如,-2)时,这意味着受影响的行数值无法由 JDBC 确定。
在大多数情况下,JDBC 预编译语句更好,因此,只要可能,jOOQ 就依赖于PreparedStatement(www.jooq.org/doc/latest/manual/sql-execution/statement-type/)。但是,我们可以通过setStatementType()或withStatementType()轻松切换到静态语句(java.sql.Statement),如下例所示(您也可以通过@Bean全局应用此方法):
int[] result = ctx.configuration().derive(new
Settings().withStatementType(StatementType.STATIC_STATEMENT))
.dsl().batch(
ctx.insertInto(SALE, SALE.FISCAL_YEAR,
SALE.EMPLOYEE_NUMBER, SALE.SALE_, SALE.FISCAL_MONTH,
SALE.REVENUE_GROWTH)
.values((Integer) null, null, null, null, null))
.bind(2005, 1370L, 1282.64, 1, 0.0)
.bind(2004, 1370L, 3938.24, 1, 0.0)
...
.execute();
这次,绑定值将自动内联到静态批量查询中。这与本节第一例中的batch(Query... queries)使用相同。
显然,使用绑定值对于插入(更新、删除等)对象集合也是很有用的。例如,考虑以下SimpleSale(POJO)列表:
List<SimpleSale> sales = List.of(
new SimpleSale(2005, 1370L, 1282.64, 1, 0.0),
new SimpleSale(2004, 1370L, 3938.24, 1, 0.0),
new SimpleSale(2004, 1370L, 4676.14, 1, 0.0));
首先,我们定义适当的BatchBindStep,包含一个INSERT(它也可以是UPDATE、DELETE等):
BatchBindStep batch = ctx.batch(ctx.insertInto(SALE,
SALE.FISCAL_YEAR, SALE.EMPLOYEE_NUMBER, SALE.SALE_,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values((Integer) null, null, null, null, null));
第二步,绑定值并执行批次:
sales.forEach(s -> batch.bind(s.getFiscalYear(),
s.getEmployeeNumber(), s.getSale(),
s.getFiscalMonth(),s.getRevenueGrowth()));
batch.execute();
您可以在捆绑的代码中找到这些示例,BatchInserts旁边还有批量更新示例BatchUpdates和删除示例BatchDeletes。但是,我们也可以将这些类型的所有语句组合在一个单独的batch()方法中,如下所示:
int[] result = ctx.batch(
ctx.insertInto(SALE, SALE.FISCAL_YEAR,SALE.EMPLOYEE_NUMBER,
SALE.SALE_, SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values(2005, 1370L, 1282.64, 1, 0.0),
ctx.insertInto(SALE, SALE.FISCAL_YEAR, SALE.EMPLOYEE_NUMBER,
SALE.SALE_, SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values(2004, 1370L, 3938.24, 1, 0.0),
...
ctx.update(EMPLOYEE).set(EMPLOYEE.SALARY,
EMPLOYEE.SALARY.plus(1_000))
.where(EMPLOYEE.SALARY.between(100_000, 120_000)),
ctx.update(EMPLOYEE).set(EMPLOYEE.SALARY,
EMPLOYEE.SALARY.plus(5_000))
.where(EMPLOYEE.SALARY.between(65_000, 80_000)),
...
ctx.deleteFrom(BANK_TRANSACTION)
.where(BANK_TRANSACTION.TRANSACTION_ID.eq(1)),
ctx.deleteFrom(BANK_TRANSACTION)
.where(BANK_TRANSACTION.TRANSACTION_ID.eq(2)),
...
).execute();
在使用batch()方法时,jOOQ 将始终保留您的语句顺序,并将所有这些语句作为一个单独的批次(往返)发送到数据库。此示例在名为CombineBatchStatements的应用程序中可用。
在批量准备过程中,语句被累积在内存中,因此您必须注意避免内存问题,如 OOMs。您可以通过在for循环中调用 jOOQ 批量来轻松模拟批量大小,该循环限制语句的数量为某个值。您可以在单个事务中执行所有批次(如果出现问题,则回滚所有批次)或在每个单独的事务中执行每个批次(如果出现问题,则仅回滚最后一个批次)。您可以在捆绑的代码中看到这些方法,例如EmulateBatchSize。
同步批量最终会以execute()调用结束,而异步批量最终会以executeAsync()调用结束。例如,考虑名为AsyncBatch的应用程序。接下来,让我们谈谈批量记录。
批量记录
批量处理记录是另一回事。jOOQ API 为批量处理记录依赖于每个语句类型的一组专用方法,如下所示:
-
INSERT:batchInsert()遵循TableRecord.insert()语义 -
UPDATE:batchUpdate()遵循UpdatableRecord.update()语义 -
DELETE:batchDelete()遵循UpdatableRecord.delete()语义 -
MERGE:batchMerge()遵循UpdatableRecord.merge()语义 -
INSERT/UPDATE:batchStore()遵循UpdatableRecord.store()语义
接下来,我们将逐一介绍这些语句,但在那之前,让我们指出一个重要方面。默认情况下,所有这些方法都会为执行特定类型的查询并绑定值创建批量操作。jOOQ 只要记录生成相同的 SQL 并带有绑定变量,就会保留记录的顺序;否则,顺序会改变,将具有相同 SQL 并带有绑定变量的记录分组在一起。因此,在最佳情况下,当所有记录都生成相同的 SQL 并带有绑定变量时,将只有一个批量操作,而在最坏情况下,记录的数量将与批量操作的数量相等。简而言之,将要执行的批量操作数量等于生成的不同 SQL 语句的数量。
如果我们从默认的 PreparedStatement 切换到静态 Statement (StatementType.STATIC_STATEMENT),那么记录值将被内联。这次,将只有一个批量操作,并且记录的顺序将完全保留。显然,当必须保留记录的顺序并且/或者批量非常大,重新排列记录可能耗时且导致大量批量操作时,这是首选的。
批量记录插入、更新和删除
让我们考虑以下 Record 集合:
SaleRecord sr1 = new SaleRecord(…, 2005, 1223.23, 1370L, …);
SaleRecord sr2 = new SaleRecord(…, 2004, 5483.33, 1166L, …);
SaleRecord sr3 = new SaleRecord(…, 2005, 9022.21, 1370L, …);
批量插入这些记录可以这样做:
int[] result = ctx.batchInsert(sr3, sr1, sr2).execute();
在这种情况下,这些记录在一个批量操作中插入,因为为 sr1 到 sr3 生成的 SQL 并带有绑定变量是相同的。此外,批量操作保留了记录的顺序(即 sr3, sr1, 和 sr2)。如果我们想更新这些记录,并分别删除这些记录,那么我们将 batchInsert() 替换为 batchUpdate(),并分别替换为 batchDelete()。您也可以将这些记录放在一个集合中,并将该集合传递给 batchInsert(),如下例所示:
List<SaleRecord> sales = List.of(sr3, sr1, sr2);
int[] result = ctx.batchInsert(sales).execute();
接下来,让我们考虑记录的混合:
SaleRecord sr1 = new SaleRecord(…);
SaleRecord sr2 = new SaleRecord(…);
BankTransactionRecord bt1 = new BankTransactionRecord(…);
SaleRecord sr3 = new SaleRecord(…);
SaleRecord sr4 = new SaleRecord(…);
BankTransactionRecord bt2 = new BankTransactionRecord(…);
调用 batchInsert(bt1, sr1, sr2, bt2, sr4, sr3) 将执行两个批量操作,一个用于 SaleRecord,另一个用于 BankTransactionRecord。jOOQ 会将 SaleRecord (sr1, sr2, sr3, 和 sr4) 分组在一个批量操作中,并将 BankTransactionRecord (bt1 和 bt2) 分组在另一个批量操作中,因此记录的顺序不会保留(或者部分保留),因为 (bt1, sr1, sr2, bt2, sr4, 和 sr3) 可能会变成 ((bt1 和 bt2), (sr1, sr2, sr4, 和 sr3)).
最后,让我们考虑这些记录:
SaleRecord sr1 = new SaleRecord();
sr1.setFiscalYear(2005);
sr1.setSale(1223.23);
sr1.setEmployeeNumber(1370L);
sr1.setTre"d("UP");
sr1.setFiscalMonth(1);
sr1.setRevenueGrowth(0.0);
SaleRecord sr2 = new SaleRecord();
sr2.setFiscalYear(2005);
sr2.setSale(9022.21);
sr2.setFiscalMonth(1);
sr2.setRevenueGrowth(0.0);
SaleRecord sr3 = new SaleRecord();
sr3.setFiscalYear(2003);
sr3.setSale(8002.22);
sr3.setEmployeeNumber(1504L);
sr3.setFiscalMonth(1);
sr3.setRevenueGrowth(0.0);
如果我们执行 batchInsert(sr3, sr2, sr1),那么将有三个批处理操作,因为 sr1、sr2 和 sr3 产生了三个不同的 SQL,它们将具有不同的绑定变量。记录的顺序保持为 sr3、sr2 和 sr1。对于 batchUpdate() 和 batchDelete() 也适用相同的流程。
这些示例中的任何一个都可以通过简单地添加 STATIC_STATEMENT 设置来利用 JDBC 静态语句,如下所示:
int[] result = ctx.configuration().derive(new Settings()
.withStatementType(StatementType.STATIC_STATEMENT))
.dsl().batchInsert/Update/…(…).execute();
你可以在 BatchInserts、BatchUpdates 和 BatchDeletes 中练习这些示例。
批处理合并
如您从 批处理记录 部分的要点列表中已经知道,batchMerge() 对于执行批处理 MERGE 语句非常有用。主要来说,batchMerge() 符合在 第九章 中提到的 UpdatableRecord.merge() 语义,CRUD、事务和锁定。
换句话说,batchMerge() 根据方言生成模拟的 INSERT ... ON DUPLICATE KEY UPDATE 语句;在 MySQL 中,通过 INSERT ... ON DUPLICATE KEY UPDATE,在 PostgreSQL 中,通过 INSERT ... ON CONFLICT,在 SQL Server 和 Oracle 中,通过 MERGE INTO。实际上,batchMerge() 生成的 INSERT ... ON DUPLICATE KEY UPDATE 语句与记录是否已经从数据库中检索出来或现在创建无关。生成的不同 SQL 语句的数量给出了批次的数量。因此,默认情况下(这意味着默认设置、默认更改标志和没有乐观锁定),jOOQ 生成的查询将委托给数据库根据主键的唯一性来决定是插入还是更新。让我们考虑以下记录:
SaleRecord sr1 = new SaleRecord(1L, 2005, 1223.23, ...);
SaleRecord sr2 = new SaleRecord(2L, 2004, 543.33, ...);
SaleRecord sr3 = new SaleRecord(9999L, 2003, 8002.22, ...);
我们按照以下方式执行批处理合并:
int[] result = ctx.batchMerge(sr1, sr2, sr3).execute();
例如,在 PostgreSQL 中,生成的 SQL 如下所示:
INSERT INTO "public"."sale" ("sale_id",
"fiscal_year", ..., "trend")
VALUES (?, ?, ..., ?) ON CONFLICT ("sale_id") DO
UPDATE SET "sale_id" = ?,
"fiscal_year" = ?, ..., "trend" = ?
WHERE "public"."sale"."sale_id" = ?
因为 sr1(主键为 1)和 sr2(主键为 2)已经存在于 SALE 表中,数据库将决定更新它们,而 sr3(主键为 9999)将被插入,因为它在数据库中不存在。由于所有 SaleRecord 生成的带有绑定变量的 SQL 是相同的,所以将只有一个批次。记录的顺序被保留。更多示例可以在 BatchMerges 中找到。
批处理存储
batchStore() 对于执行批处理中的 INSERT 或 UPDATE 语句非常有用。主要来说,batchStore() 符合上一章中提到的 UpdatableRecord.store()。因此,与 batchMerge() 不同,后者将选择更新或插入的决定委托给数据库,batchStore() 允许 jOOQ 通过分析主键值的当前状态来决定是否生成 INSERT 或 UPDATE。
例如,如果我们依赖默认设置(这意味着默认设置、默认更改标志和没有乐观锁定),以下两个记录将用于批处理存储执行:
SaleRecord sr1 = new SaleRecord(9999L,
2005, 1223.23, 1370L, ...);
SaleRecord sr2 = ctx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(1L)).fetchOne();
sr2.setFiscalYear(2006);
int[] result = ctx.batchStore(sr1, sr2).execute();
由于 sr1 是一个全新的 SaleRecord,它将导致 INSERT。另一方面,sr2 从数据库中获取并更新了,所以它将导致 UPDATE。显然,生成的 SQL 语句不相同,因此,将有两个批处理操作,并且顺序将保留为 sr1 和 sr2。
这里还有一个更新 SaleRecord 并添加更多内容的示例:
Result<SaleRecord> sales = ctx.selectFrom(SALE).fetch();
// update all sales
sales.forEach(sale -> { sale.setTrend("UP"); });
// add more new sales
sales.add(new SaleRecord(...));
sales.add(new SaleRecord(...));
...
int[] result = ctx.batchStore(sales)
.execute();
我们有两种批处理操作:一个包含所有更新所需更新获取的 SaleRecord 的批处理,以及一个包含所有插入所需插入新 SaleRecord 的批处理。
在捆绑的代码中,你可以找到更多无法在此列出因为它们很大的示例,所以请花时间练习 BatchStores 中的示例。这是本节最后一个主题。接下来,让我们谈谈 批处理连接 API。
批处理连接
除了前面提到的批处理功能外,jOOQ 还提供了一个名为 org.jooq.tools.jdbc.BatchedConnection 的 API。其主要目的是缓冲现有的 jOOQ/JDBC 语句,并在不要求我们更改 SQL 字符串或执行顺序的情况下批量执行它们。我们可以显式或间接地通过 DSLContext.batched(BatchedRunnable runnable) 或 DSLContext.batchedResult(BatchedCallable<T> callable) 来使用 BatchedConnection。它们之间的区别在于前者返回 void,而后者返回 T。
例如,假设我们有一个(服务)方法,它会产生大量的 INSERT 和 UPDATE 语句:
void insertsAndUpdates(Configuration c) {
DSLContext ctxLocal = c.dsl();
ctxLocal.insertInto(…).execute();
…
ctxLocal.update(…).execute();
…
}
为了提高此方法的速度,我们只需通过 DSLContext.batched() 添加批收集代码,如下所示:
public void batchedInsertsAndUpdates() {
ctx.batched(this::insertsAndUpdates);
}
当然,如果 INSERT 语句由 inserts(Configuration c) 方法生成,而 UPDATE 语句由另一个方法 updates(Configuration c) 生成,那么两者都应该被收集:
public void batchedInsertsAndUpdates() {
ctx.batched((Configuration c) -> {
inserts(c);
updates(c);
});
}
此外,此 API 还可以用于批处理 jOOQ 记录。以下是一个示例:
ctx.batched(c -> {
Result<SaleRecord> records = c.dsl().selectFrom(SALE)
.limit(5).fetch();
records.forEach(record -> {
record.setTrend("CONSTANT");
...
record.store();
});
});
或者,这里还有一个例子:
List<SaleRecord> sales = List.of(
new SaleRecord(...), new SaleRecord(...), ...
);
ctx.batched(c -> {
for (SaleRecord sale : sales) {
c.dsl().insertInto(SALE)
.set(sale)
.onDuplicateKeyUpdate()
.set(SALE.SALE_, sale.getSale())
.execute();
}
}); // batching is happening here
注意,jOOQ 将保留你的语句顺序,这个顺序可能会影响批处理操作的数量。仔细阅读以下说明,因为它在工作与此 API 时非常重要。
重要提示
jOOQ 每次检测到以下情况时,都会自动创建一个新的批处理:
-
SQL 字符串发生变化(甚至空白也被视为变化)。
-
一个查询产生结果(例如,
SELECT);此类查询不是批处理的一部分。 -
一个静态语句出现在预处理语句之后(或反之亦然)。
-
调用了一个 JDBC 交互(事务提交,连接关闭等)。
批处理大小阈值已达到。
作为一项重要限制,请注意,受影响的行数值始终由 JDBC PreparedStatement.executeUpdate() 报告为 0。
注意,上一条说明中的最后一个项目指的是批处理大小阈值。嗯,这个 API 可以利用 Settings.batchSize(),它设置最大批处理语句大小如下:
@Bean
public Settings jooqSettings() {
return new Settings().withBatchSize(30);
}
此外,如果我们显式地依赖于 BatchedConnection,那么我们可以包装 JDBC 连接,并通过 BatchedConnection(Connection delegate, int batchSize) 构造函数将批大小作为参数指定,如下所示(这里批大小设置为 2;请考虑阅读注释):
try ( BatchedConnection conn = new BatchedConnection(
DriverManager.getConnection(
"jdbc:mysql://localhost:3306/classicmodels",
"root", "root"), 2)) {
try ( PreparedStatement stmt = conn.prepareStatement(
"insert into `classicmodels`.`sale` (`fiscal_year`,
`employee_number`, `sale`, `fiscal_month`,
`revenue_growth`) " + "values (?, ?, ?, ?, ?);")) {
// the next 2 statements will become the first batch
stmt.setInt(1, 2004);
stmt.setLong(2, 1166L);
stmt.setDouble(3, 543.33);
stmt.setInt(4, 1);
stmt.setDouble(5, 0.0);
stmt.executeUpdate();
stmt.setInt(1, 2005);
stmt.setLong(2, 1370L);
stmt.setDouble(3, 9022.20);
stmt.setInt(4, 1);
stmt.setDouble(5, 0.0);
stmt.executeUpdate();
// reached batch limit so this is the second batch
stmt.setInt(1, 2003);
stmt.setLong(2, 1166L);
stmt.setDouble(3, 3213.0);
stmt.setInt(4, 1);
stmt.setDouble(5, 0.0);
stmt.executeUpdate();
}
// since the following SQL string is different,
// next statements represents the third batch
try ( PreparedStatement stmt = conn.prepareStatement(
"insert into `classicmodels`.`sale` (`fiscal_year`,
`employee_number`, `sale`, `fiscal_month`,
`revenue_growth`,`trend`) "
+ "values (?, ?, ?, ?, ?, ?);")) {
stmt.setInt(1, 2004);
stmt.setLong(2, 1166L);
stmt.setDouble(3, 4541.35);
stmt.setInt(4, 1);
stmt.setDouble(5, 0.0);
stmt.setString(6, "UP");
stmt.executeUpdate();
stmt.setInt(1, 2005);
stmt.setLong(2, 1370L);
stmt.setDouble(3, 1282.64);
stmt.setInt(4, 1);
stmt.setDouble(5, 0.0);
stmt.setString(6, "DOWN");
stmt.executeUpdate();
}
} catch (SQLException ex) { … }
此外,BatchedConnection 实现了 java.sql.Connection,因此你可以使用 Connection 的所有方法,包括用于塑造事务行为的方法。更多示例可以在 Batched 中找到。
接下来,让我们解决在 PostgreSQL 和 SQL Server 中遇到的两个特殊情况。
PostgreSQL/Oracle 中的批处理和获取序列
如你所知,PostgreSQL/Oracle 可以依赖序列来提供主键(和其他唯一值)。例如,我们的 PostgreSQL employee 表使用以下序列来生成 employee_number 的序列值:
CREATE TABLE "employee" (
"employee_number" BIGINT NOT NULL,
...
CONSTRAINT "employee_pk" PRIMARY KEY ("employee_number"),
...
);
CREATE SEQUENCE "employee_seq" START 100000 INCREMENT 10
MINVALUE 100000 MAXVALUE 10000000
OWNED BY "employee"."employee_number";
但是,在批处理的情况下,从应用程序中获取 employee 主键需要为每个主键进行一次数据库往返(SELECT)。显然,有 n 个 INSERT 语句的批次并执行 n 次往返(SELECT 语句)只是为了获取它们的主键,这是一个性能损失。幸运的是,jOOQ 至少提供了两种解决方案。其中之一是在 SQL 语句中内联序列引用(EMPLOYEE_SEQ.nextval() 调用):
int[] result = ctx.batch(
ctx.insertInto(EMPLOYEE, EMPLOYEE.EMPLOYEE_NUMBER,
EMPLOYEE.LAST_NAME, ...)
.values(EMPLOYEE_SEQ.nextval(), val("Lionel"), ...),
ctx.insertInto(EMPLOYEE, EMPLOYEE.EMPLOYEE_NUMBER,
EMPLOYEE.LAST_NAME...)
.values(EMPLOYEE_SEQ.nextval(), val("Ion"), ...),
...
).execute();
另一种方法是预先通过 SELECT 查询获取多个 n 个主键:
var ids = ctx.fetch(EMPLOYEE_SEQ.nextvals(n));
然后,使用这些主键进行批量操作(注意 ids.get(n).value1() 调用):
int[] result = ctx.batch(
ctx.insertInto(EMPLOYEE, EMPLOYEE.EMPLOYEE_NUMBER,
EMPLOYEE.LAST_NAME, ...)
.values(ids.get(0).value1(), "Lionel", ...),
ctx.insertInto(EMPLOYEE, EMPLOYEE.EMPLOYEE_NUMBER,
EMPLOYEE.LAST_NAME, ...)
.values(ids.get(1).value1(), "Ion", ...),
...
).execute();
这两个示例都依赖于 public static final EMPLOYEE_SEQ 字段,或者更确切地说,依赖于 jooq.generated.Sequences.EMPLOYEE_SEQ。主要来说,jOOQ 代码生成器将为每个数据库序列生成一个序列对象,并且每个这样的对象都可以访问 nextval()、currval()、nextvals(int n) 等方法,这些方法将在 第十一章,jOOQ 键 中介绍。
当然,如果你依赖于从 (BIG)SERIAL 自动生成的序列,或者依赖于作为默认值关联的序列(例如,在 sale 表中,我们有一个与 sale_id 关联的序列 DEFAULT NEXTVAL ('sale_seq')),那么批量操作最简单的方法就是在语句中省略主键字段,数据库将完成剩余的工作。之前的示例以及更多示例可以在 PostgreSQL 的 BatchInserts 中找到。
SQL Server 的 IDENTITY 列和显式值
为 SQL Server 的 IDENTITY 列插入显式值会导致错误 当 IDENTITY_INSERT 设置为 OFF 时,无法在表 'table_name' 中为标识列插入显式值。绕过此错误可以通过在 INSERT 之前将 IDENTITY_INSERT 设置为 ON 来完成。在批处理的情况下,这可以像下面这样完成:
int[] result = ctx.batch(
ctx.query("SET IDENTITY_INSERT [sale] ON"),
ctx.insertInto(SALE, SALE.SALE_ID, SALE.FISCAL_YEAR, …)
.values(1L, 2005, …),
ctx.insertInto(SALE, SALE.SALE_ID, SALE.FISCAL_YEAR, …)
.values(2L, 2004, …),
...
ctx.query("SET IDENTITY_INSERT [sale] OFF")
).execute();
你可以在 SQL Server 的 BatchInserts 中找到这个示例。接下来,让我们谈谈批量操作。
批量操作
在 jOOQ 中编写批量查询只是使用 jOOQ DSL API 的问题。例如,一个批量插入 SQL 看起来像这样:
INSERT IGNORE INTO `classicmodels`.`order` (
`order_date`, `required_date`, `shipped_date`,
`status`, `comments`, `customer_number`, `amount`)
VALUES (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?)
这可以在 jOOQ 中通过链式调用values()方法表示:
ctx.insertInto(ORDER)
.columns(ORDER.ORDER_DATE, ORDER.REQUIRED_DATE,
ORDER.SHIPPED_DATE, ORDER.STATUS,
ORDER.COMMENTS,ORDER.CUSTOMER_NUMBER,
ORDER.AMOUNT)
.values(LocalDate.of(2004,10,22), LocalDate.of(2004,10,23),
LocalDate.of(2004,10,23", "Shipped",
"New order inserted...", 363L, BigDecimal.valueOf(322.59))
.values(LocalDate.of(2003,12,2), LocalDate.of(2003,1,3),
LocalDate.of(2003,2,26), "Resolved",
"Important order ...", 128L, BigDecimal.valueOf(455.33))
...
.onDuplicateKeyIgnore() // onDuplicateKeyUpdate().set(...)
.execute()
或者,你可以使用以下批量更新 SQL:
update `classicmodels`.`sale`
set
`classicmodels`.`sale`.`sale` = case when
`classicmodels`.`sale`.`employee_number` = ? then (
`classicmodels`.`sale`.`sale` + ?
) when `classicmodels`.`sale`.`employee_number` = ? then (
`classicmodels`.`sale`.`sale` + ?
) when `classicmodels`.`sale`.`employee_number` = ? then (
`classicmodels`.`sale`.`sale` + ?
) end
where
`classicmodels`.`sale`.`employee_number` in (?, ?, ?)
这可以在 jOOQ 中表示如下:
ctx.update(SALE).set(SALE.SALE_,
case_()
.when(SALE.EMPLOYEE_NUMBER.eq(1370L), SALE.SALE_.plus(100))
.when(SALE.EMPLOYEE_NUMBER.eq(1504L), SALE.SALE_.plus(500))
.when(SALE.EMPLOYEE_NUMBER.eq(1166L), SALE.SALE_.plus(1000)))
.where(SALE.EMPLOYEE_NUMBER.in(1370L, 1504L, 1166L))
.execute();
更多示例可以在 MySQL 的Bulk中找到。接下来,让我们谈谈Loader API,它具有内置的批量支持。
加载(加载器 API)
每当我们需要将来自不同来源(CSV、JSON 等)的数据加载(导入)到我们的数据库表中时,我们可以依赖 jOOQ 加载器 API(org.jooq.Loader)。这是一个流畅的 API,它允许我们轻松应对最重要的挑战,例如处理重复键、批量处理、分批处理、提交和错误处理。
加载器 API 语法
通常,我们有一个包含要导入数据的文件,这些数据以 CSV 或 JSON 等常见格式存储,我们自定义加载器 API 的一般语法以适应我们的需求:
ctx.loadInto(TARGET_TABLE)
.[options]
.[source and source to target mapping]
.[listeners]
.[execution and error handling]
虽然TARGET_TABLE显然是应该导入数据的那张表,但让我们看看我们有哪些选项。
选项
我们可以主要区分三种用于自定义导入过程的选项类型:处理重复键的选项、限制选项和处理失败(错误)的选项。以下图表突出了每个选项类别以及可以用于流畅链式调用这些选项的有效路径:

图 10.3 – 加载器 API 选项
让我们探索这些类别中的每一个,从处理重复键的类别开始。
重复键选项
当表中存在唯一键并且我们尝试导入具有相同键的记录时,会发生重复键。通过唯一键,jOOQ 指的是任何唯一键,而不仅仅是主键。
因此,处理重复键可以通过onDuplicateKeyError()来完成,这是默认行为,或者通过onDuplicateKeyIgnore()或onDuplicateKeyUpdate()。默认行为是在存在任何重复键时抛出异常。
通过显式使用onDuplicateKeyIgnore(),我们指示 jOOQ 跳过任何重复键而不会抛出异常(这是合成的ON DUPLICATE KEY IGNORE子句,jOOQ 可以根据方言进行模拟)。我们可以通过onDuplicateKeyUpdate()指示 jOOQ 执行UPDATE而不是INSERT(这是合成的ON DUPLICATE KEY UPDATE子句,jOOQ 可以根据方言进行模拟)。
限制选项
有三种限制选项可以用来微调导入过程。这些选项涉及批量处理、分批处理和提交。jOOQ 允许我们显式使用这些选项的任何组合,或者依赖以下默认设置:无批量处理、分批处理和提交。
批量可以通过 bulkNone()(这是默认设置,表示不使用批量)设置,bulkAfter(int rows)(允许我们通过多行 INSERT(例如,insert into ... (...) values (?, ?, ?,...), (?, ?, ?,...), (?, ?, ?,...), ...)指定每个批量将插入多少行),以及 bulkAll()(尝试从整个数据源创建一个批量)。
如 图 10.3 所示,bulkNone() 是唯一一个可以在处理重复值的所有选项之后链式调用的。bulkAfter() 和 bulkAll() 方法只能在 onDuplicateKeyError() 之后链式调用。此外,bulkNone()、bulkAfter() 和 bulkAll() 是互斥的。
可以通过默认的 batchNone() 避免批处理,或者可以通过 batchAfter(int bulk) 或 batchAll() 明确设置。通过 batchAfter(int bulk) 明确指定应作为单个 JDBC 批处理语句发送到服务器的批量语句数量。另一方面,可以通过 batchAll() 发送包含所有批量的单个批量。如果没有使用批量(bulkNone()),那么每行就像是一个批量,因此,例如,batchAfter(3) 表示创建每批包含三行的批次。
如 图 10.3 所示,batchNone()、batchAfter() 和 batchAll() 是互斥的。
最后,将数据提交到数据库可以通过四种专用方法进行控制。默认情况下,commitNone() 将提交和回滚操作留给客户端代码(例如,通过 commitNone(),我们可以允许 Spring Boot 处理提交和回滚)。但是,如果我们想在一定数量的批次之后提交,那么我们必须使用 commitAfter(int batches) 或便捷的 commitEach() 方法,它等同于 commitAfter(1)。而且,如果我们决定一次性提交所有批次,那么我们需要 commitAll()。如果没有使用批处理(依赖于 batchNone()),那么每个批次就像是一个批量,例如,commitAfter(3) 表示每三个批量后提交。如果没有使用批量(依赖于 bulkNone()),那么每个批量就像是一行,例如,commitAfter(3) 表示每三行后提交。
如 图 10.3 所示,commitNone()、commitAfter()、commitEach() 和 commitAll() 是互斥的。
错误选项
尝试操作(导入)大量数据是一个很容易出错的流程。其中一些错误是致命的,应该停止导入过程,而其他错误可以安全地忽略或推迟到导入后解决。在致命错误的情况下,Loader API 依赖于名为 onErrorAbort() 的方法。如果发生错误,则 Loader API 停止导入过程。另一方面,我们有 onErrorIgnore(),它指示 Loader API 跳过任何导致错误的插入操作,并尝试执行下一个操作。
特殊情况
虽然找到这些选项的最佳组合是一个基准测试的问题,但以下是一些你应该知道的事情:
-
如果我们的表中没有唯一键,那么
onDuplicateKeyUpdate()的行为与onDuplicateKeyIgnore()完全相同。 -
如果使用
bulkAll()+commitEach()或bulkAll()+commitAfter(),那么 jOOQ 强制使用commitAll()。 -
如果使用
batchAll()+commitEach()或batchAll()+commitAfter(),那么 jOOQ 强制使用commitAll()。
接下来,让我们快速概述一下支持的数据源。
导入数据源
通过特定于支持的不同数据类型的专用方法提供数据源可以完成。例如,如果数据源是 CSV 文件,那么我们依赖于 loadCSV() 方法;如果是 JSON 文件,那么我们依赖于 loadJSON() 方法;如果是 XML 文件,那么我们依赖于 loadXML() 方法。此外,我们可以通过 loadArrays() 导入数组,通过 loadRecords() 导入 jOOQ Record。
loadCSV()、loadJSON() 和 loadXML() 方法有 10+ 种风味,允许我们从 String、File、InputStream 和 Reader 加载数据。另一方面,loadArrays() 和 loadRecords() 允许我们从数组、Iterable、Iterator 或 Stream 加载数据。
监听器
Loader API 附带导入监听器,可以串联起来以跟踪导入进度。我们主要有 onRowStart(LoaderRowListener listener) 和 onRowEnd(LoaderRowListener listener)。前者指定在处理当前行之前调用的监听器,而后者指定在处理当前行之后调用的监听器。LoaderRowListener 是一个函数式接口。
执行和错误处理
Loader API 执行后,我们可以通过返回的 org.jooq.Loader 获取有意义的反馈。例如,我们可以通过 executed() 方法找到已执行的批处理/批次的数量,通过 processed() 方法找到已处理的行数,通过 stored() 方法找到存储的行数(INSERT/UPDATE),通过 ignored() 方法找到被忽略的行数(由错误或重复键引起),以及通过 errors() 方法作为 List<LoaderError> 的潜在错误。正如你将在下一节的示例中看到的那样,LoaderError 包含有关错误(如果有)的详细信息。
使用 Loader API 的示例
在所有这些理论之后,现在是时候看看加载 CSV、JSON、Record 和数组的示例了。所有这些示例都是在 Spring Boot @Transactional 的上下文中执行和剖析的。请随意在 jOOQ 事务性上下文中练习它们,只需简单地删除 @Transactional 并将代码包装如下:
ctx.transaction(configuration -> {
// Loader API code
configuration.dsl()…
});
因此,让我们先加载一些 CSV 文件。
加载 CSV
加载 CSV 通过 loadCSV() 方法完成。让我们从一个基于以下典型 CSV 文件(in.csv)的简单示例开始:
sale_id,fiscal_year,sale,employee_number,…,trend
1,2003,5282.64,1370,0,…,UP
2,2004,1938.24,1370,0,…,UP
3,2004,1676.14,1370,0,…,DOWN
…
显然,这些数据应该导入到sale表中,所以应该传递给loadInto()的TARGET_TABLE(Table<R>)是SALE。通过loadCSV()方法指向此文件,如下所示:
ctx.loadInto(SALE)
.loadCSV(Paths.get("data", "csv", "in.csv").toFile(),
StandardCharsets.UTF_8)
.fieldsCorresponding()
.execute();
此代码依赖于默认选项。注意对fieldsCorresponding()方法的调用。此方法向 jOOQ 发出信号,表示所有在SALE(具有相同名称)中对应输入字段的字段都应该被加载。实际上,在这种情况下,CSV 文件中的所有字段都在SALE表中有一个对应项,因此所有这些字段都将被导入。
但,显然,情况并不总是如此。也许我们只想加载散列字段的一个子集。在这种情况下,只需为不应加载的字段索引(位置)传递虚拟空值(这是一个基于索引/位置的字段映射)。这次,让我们通过processed()收集处理行的数量:
int processed = ctx.loadInto(SALE)
.loadCSV(Paths.get("data", "csv", "in.csv").toFile(),
StandardCharsets.UTF_8)
.fields(null, SALE.FISCAL_YEAR, SALE.SALE_,
null, null, null, null, SALE.FISCAL_MONTH,
SALE.REVENUE_GROWTH,SALE.TREND)
.execute()
.processed();
此代码仅从 CSV 加载SALE.FISCAL_YEAR、SALE.SALE_、SALE.FISCAL_MONTH、SALE.REVENUE_GROWTH和SALE.TREND。注意,我们使用了fields()方法而不是fieldsCorresponding(),因为fields()允许我们只保留所需的字段并跳过其余部分。一个 MySQL 方言的INSERT(结果)示例如下:
INSERT INTO `classicmodels`.`sale` (`fiscal_year`, `sale`,
`fiscal_month`, `revenue_growth`, `trend`)
VALUES (2005, 5243.1, 1, 0.0, 'DOWN')
虽然此 CSV 文件是一个典型的文件(第一行是标题,数据由逗号分隔等),但有时我们必须处理相当定制的 CSV 文件,如下所示:
1|2003|5282.64|1370|0|{null}|{null}|1|0.0|*UP*
2|2004|1938.24|1370|0|{null}|{null}|1|0.0|*UP*
3|2004|1676.14|1370|0|{null}|{null}|1|0.0|*DOWN*
…
此 CSV 文件包含与上一个相同的数据,只是没有标题行,数据分隔符是|,引号标记是*,而null值表示为{null}。将此 CSV 文件加载到SALE中需要以下代码:
List<LoaderError> errors = ctx.loadInto(SALE)
.loadCSV(Paths.get("data", "csv", "in.csv").toFile(),
StandardCharsets.UTF_8)
.fields(SALE.SALE_ID, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.HOT, SALE.RATE, SALE.VAT,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH, SALE.TREND)
.ignoreRows(0)
.separator('|').nullString("{null}").quote('*')
.execute()
.errors();
首先,由于没有标题,我们依赖于fields()来显式指定字段列表(SALE_ID在 CSV 中映射到索引1,FISCAL_YEAR映射到索引2等)。接下来,我们调用ignoreRows(0);默认情况下,jOOQ 会跳过第一行,这被认为是 CSV 文件的标题行,但由于在这种情况下没有标题,我们必须指示 jOOQ 将第一行视为包含数据的一行。显然,此方法也适用于跳过n行。更进一步,我们调用separator()、nullString()和quote()来覆盖默认值。最后,我们调用errors()并收集List<LoaderError>中的潜在错误。这是一个可选步骤,与这个特定示例无关。在捆绑的代码(LoadCSV for MySQL)中,你可以看到如何遍历此列表并提取有关加载过程中发生的事情的宝贵信息。此外,你还将看到更多加载 CSV 文件的示例。接下来,让我们探索更多加载 JSON 文件的示例。
加载 JSON
通过loadJSON()方法进行 JSON 加载。让我们从一个 JSON 文件开始:
{
"fields": [
{
"schema": "classicmodels",
"table": "sale",
"name": "sale_id",
"type": "BIGINT"
},
...
],
"records": [
[1, 2003, 5282.64, 1370, 0, null, null, 1, 0.0, "UP"],
[2, 2004, 1938.24, 1370, 0, null, null, 1, 0.0, "UP"],
...
]
}
这个 JSON 文件之前是通过formatJSON()导出的。注意"fields"标题,这对于通过fieldsCorresponding()方法将此文件加载到SALE表中非常有用。如果没有标题,fieldsCorresponding()方法无法产生预期的结果,因为输入字段缺失。但是,如果我们依赖于fields()方法,那么我们可以列出所需的字段(所有或其中一部分)并依赖于基于索引的映射,无需担心"fields"标题的存在或缺失。此外,这次,让我们添加一个onRowEnd()监听器:
ctx.loadInto(SALE)
.loadJSON(Paths.get("data", "json", "in.json").toFile(),
StandardCharsets.UTF_8)
.fields(null, SALE.FISCAL_YEAR, SALE.SALE_, null, null,
null, null, SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH,
SALE.TREND)
.onRowEnd(ll -> {
System.out.println("Processed row: "
+ Arrays.toString(ll.row()));
System.out.format("Executed: %d, ignored: %d, processed:
%d, stored: %d\n", ll.executed(), ll.ignored(),
ll.processed(), ll.stored());
})
.execute();
每处理完一行,你将在日志中看到一个输出,如下所示:
Processed row: [28, 2005, 5243.1, 1504, …, DOWN]
Executed: 28, ignored: 0, processed: 28, stored: 28
但是,让我们看看一个没有"fields"标题的 JSON 文件,如下所示:
[
{
"fiscal_month": 1,
"revenue_growth": 0.0,
"hot": 0,
"vat": null,
"rate": null,
"sale": 5282.64013671875,
"trend": "UP",
"sale_id": 1,
"fiscal_year": 2003,
"employee_number": 1370
},
{
…
},
…
这种类型的 JSON 可以通过fieldsCorresponding()或通过fields()加载。由于字段名称作为 JSON 键可用,fieldsCorresponding()方法可以正确映射它们。使用fields()时,应记住这个 JSON 中键的顺序。因此,"fiscal_month"位于索引1,"revenue_growth"位于索引2,依此类推。以下是一个只加载"fiscal_month","revenue_growth","sale","fiscal_year"和"employee_number"的示例:
int processed = ctx.loadInto(SALE)
.loadJSON(Paths.get("data", "json", "in.json").toFile(),
StandardCharsets.UTF_8)
.fields(SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH,
null, null, null, SALE.SALE_, null, null,
SALE.FISCAL_YEAR, SALE.EMPLOYEE_NUMBER)
.execute()
.processed();
但是,有时缺失的数据就在 JSON 本身中,如下所示:
[
{
"sale_id": 1,
"fiscal_year": 2003,
"sale": 5282.64
"fiscal_month": 1,
"revenue_growth": 0.0
},
…
这里是另一个示例:
[
[
1,
2003,
5282.64,
1,
0.0
],
…
这次,在两种情况下,我们都必须依赖于fields(),如下所示:
ctx.loadInto(SALE)
.loadJSON(Paths.get("data", "json", "in.json").toFile(),
StandardCharsets.UTF_8)
.fields(SALE.SALE_ID, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.execute();
接下来,假设我们有一个 JSON 文件,应该以每批2(行)的大小导入到数据库中,因此我们需要batchAfter(2)。提交(如所有之前的示例)将通过 Spring Boot 的@Transactional来完成:
@Transactional
public void loadJSON () {
int executed = ctx.loadInto(SALE)
.batchAfter(2)// each *batch* has 2 rows
.commitNone() // this is default, so it can be omitted
.loadJSON(Paths.get("data", "json", "in.json").toFile(),
StandardCharsets.UTF_8)
.fieldsCorresponding()
.execute()
.executed();
}
由于commitNone()是默认行为,它可以省略。本质上,commitNone()允许@Transactional处理提交/回滚操作。默认情况下,@Transactional在注解方法结束时提交事务。如果出现问题,整个有效负载(所有批次)将回滚。但是,如果你移除@Transactional,则auto-commit =true生效。这将在每个批次(每两行)之后提交。如果出现问题,则没有回滚操作,但由于我们依赖于默认设置onDuplicateKeyError()和onErrorAbort(),加载过程将立即中止。如果我们移除@Transactional并将auto-commit设置为false(spring.datasource.hikari.auto-commit=false),则不会提交任何内容。
这个示例通过executed()返回已执行的批次数。例如,如果有 36 行通过batchAfter(2)处理,那么executed()返回 18。
接下来,让我们考虑一个包含重复键的 JSON 文件。每次发现重复键时,Loader API 应跳过它,并在最后报告忽略的行数。此外,Loader API 应在每批三行后提交:
int ignored = ctx.loadInto(SALE)
.onDuplicateKeyIgnore()
.batchAfter(3) // each *batch* has 3 rows
.commitEach() // commit each batch
.loadJSON(Paths.get("data", "json", "in.json").toFile(),
StandardCharsets.UTF_8)
.fieldsCorresponding()
.execute()
.ignored();
如果你想执行UPDATE而不是忽略重复键,只需将onDuplicateKeyIgnore()替换为onDuplicateKeyUpdate()。
最后,让我们使用 bulkAfter(2)、batchAfter(3) 和 commitAfter(3) 导入 JSON。换句话说,每个批量有两行,每个批次有三个批量。因此,在三个批次之后,九个批量提交,即在 18 行之后,您将得到以下内容:
int inserted = ctx.loadInto(SALE)
.bulkAfter(2) // each *bulk* has 2 rows
.batchAfter(3) // each *batch* has 3 *bulks*, so 6 rows
.commitAfter(3) // commit after 3 *batches*, so after 9
// *bulks*, so after 18 rows
.loadJSON(Paths.get("data", "json", "in.json").toFile(),
StandardCharsets.UTF_8)
.fieldsCorresponding()
.execute()
.stored();
如果出现问题,最后未提交的批次将被回滚,而不会影响已提交的批次。更多示例可以在捆绑的代码 LoadJSON 中找到,适用于 MySQL。
加载记录
通过 Loader API 加载 jOOQ Record 是一个简单的过程,通过 loadRecords() 方法实现。让我们考虑以下记录集:
Result<SaleRecord> result1 = …;
Result<Record3<Integer, Double, String>> result2 = …;
Record3<Integer, Double, String>[] result3 = …;
SaleRecord r1 = new SaleRecord(1L, …);
SaleRecord r2 = new SaleRecord(2L, …);
SaleRecord r3 = new SaleRecord(3L, …);
加载它们可以这样做:
ctx.loadInto(SALE)
.loadRecords(result1)
.fields(null, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.HOT, SALE.RATE, SALE.VAT,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH, SALE.TREND)
.execute();
ctx.loadInto(SALE).loadRecords(result2/result3)
.fieldsCorresponding()
.execute();
ctx.loadInto(SALE).loadRecords(r1, r2, r3)
.fieldsCorresponding()
.execute();
让我们看看如何加载以下 Record 映射:
Map<CustomerRecord, CustomerdetailRecord> result = …;
因此,CustomerRecord 应该加载到 CUSTOMER 中,而 CustomerdetailRecord 应该加载到 CUSTOMERDETAIL 中。为此,我们可以使用 Map.keySet() 和 Map.values() 如下:
ctx.loadInto(CUSTOMER)
.onDuplicateKeyIgnore()
.loadRecords(result.keySet())
.fieldsCorresponding()
.execute();
ctx.loadInto(CUSTOMERDETAIL)
.onDuplicateKeyIgnore()
.loadRecords(result.values())
.fieldsCorresponding()
.execute();
更多示例可以在捆绑的代码 LoadRecords 中找到,适用于 MySQL。
加载数组
加载数组是通过 loadArrays() 方法实现的。让我们考虑以下包含应加载到 SALE 表中的数据的数组:
Object[][] result = ctx.selectFrom(…).fetchArrays();
加载这个数组可以这样做:
ctx.loadInto(SALE)
.loadArrays(Arrays.stream(result)) // Arrays.asList(result)
.fields(null, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.HOT, SALE.RATE, SALE.VAT,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH, SALE.TREND)
.execute();
这里是另一个依赖于 loadArrays(Object[]... os) 的例子:
int executed = ctx.loadInto(SALE)
.onDuplicateKeyIgnore()
.batchAfter(2)
.commitEach()
.loadArrays(
new Object[]{1, 2005, 582.64, 1370, 0,… , "UP"},
new Object[]{2, 2005, 138.24, 1370, 0,… , "DOWN"},
new Object[]{3, 2005, 176.14, 1370, 0,… , "DOWN"})
.fields(SALE.SALE_ID, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.HOT, SALE.RATE,
SALE.VAT, SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH,
SALE.TREND)
.execute()
.ignored();
您可以在捆绑的代码 LoadArrays 中查看这些示例,这些示例未在此列出,适用于 MySQL。现在是总结本章内容的时候了。
概述
在本章中,我们涵盖了四个重要主题:导出、分批、批量处理和加载。如您所见,jOOQ 提供了专门的 API 来完成这些任务,这些任务在底层需要大量复杂的代码。通常,jOOQ 简化了复杂性,并允许我们专注于我们必须做的事情,而不是如何去做。例如,看到将 CSV 或 JSON 文件加载到数据库中的代码片段只需几秒钟就完成,同时具有流畅和顺畅的错误处理控制、诊断输出、批量处理、分批和提交控制的支持,这真是太令人惊讶了。
在下一章中,我们将介绍 jOOQ 键。
第十一章:jOOQ 键
为我们的表选择合适的键类型对我们的查询有显著的好处。jOOQ 通过支持广泛范围的键来维持这一说法,从众所周知的唯一键和主键到复杂的内嵌键和合成/代理键。最常用的合成标识符(或代理标识符)是数字或 UUID。与自然键相比,代理标识符在现实世界中没有意义或对应物。代理标识符可以通过数值序列生成器(例如,一个身份或序列)或伪随机数生成器(例如,GUID 或 UUID)生成。此外,让我利用这个上下文来回忆一下,在集群环境中,大多数关系型数据库依赖于 数值序列 和每个节点的不同偏移量来避免冲突的风险。使用 数值序列 而不是 UUID,因为它们比 UUID 占用更少的内存(一个 UUID 需要 16 字节,而 BIGINT 需要 8 字节,INTEGER 需要 4 字节),并且索引的使用性能更优。此外,由于 UUID 不是顺序的,它们在集群索引级别引入了性能惩罚。更确切地说,我们将讨论一个被称为 索引碎片化 的问题,这是由于 UUID 是随机的。一些数据库(例如,MySQL 8.0)在缓解 UUID 性能惩罚方面有显著的改进(有三个新函数 – UUID_TO_BIN、BIN_TO_UUID 和 IS_UUID),而其他数据库仍然容易受到这些问题的影响。正如 Rick James 所强调的,“如果你不能避免使用 UUID(这将是我首先推荐的做法),那么...”建议阅读他的文章(mysql.rjweb.org/doc.php/uuid),以深入了解主要问题和潜在解决方案。
现在,让我们回到我们的章节,它将涵盖以下主题:
-
获取数据库生成的主键
-
在可更新记录上抑制主键返回
-
更新可更新记录的主键
-
使用数据库序列
-
插入 SQL Server IDENTITY
-
获取 Oracle ROWID 伪列
-
比较复合主键
-
使用内嵌键工作
-
使用 jOOQ 合成对象
-
覆盖主键
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter11。
获取数据库生成的主键
一个典型的场景是在通过insertInto()方法或可更新记录的insert()方法执行INSERT操作后获取数据库生成的(标识)主键。如果你使用insertInto()(DSL.insertInto()或DSLContext.insertInto()),可以通过returningResult()/returning()方法获取数据库生成的主键。例如,SALE的标识主键在 MySQL 中通过AUTO_INCREMENT,在 SQL Server 中通过IDENTITY,由于历史原因(因为现在两者都支持标准 SQL IDENTITY列),在 PostgreSQL 和 Oracle 中通过数据库序列。在这些所有情况下,SALE生成的标识主键可以像这样获取(SALE.SALE_ID):
long insertedId = ctx.insertInto(SALE, SALE.FISCAL_YEAR,
SALE.SALE_, SALE.EMPLOYEE_NUMBER, SALE.FISCAL_MONTH,
SALE.REVENUE_GROWTH)
.values(2004, 2311.42, 1370L, 1, 0.0)
.returningResult(SALE.SALE_ID)
.fetchOneInto(long.class);
// .fetchOne(); to fetch Record1<Long>
或者,一种方便的方法依赖于getIdentity()方法,如下所示:
.returningResult(SALE.getIdentity().getField())
然而,当你的表只有一个标识列时,这种方法是有用的;否则,最好明确列出应该返回的标识。但是,请别误解我——即使某些数据库(例如,PostgreSQL)支持多个标识,这仍然是一种相当不寻常的方法,我个人不喜欢使用,但我会在本章中介绍它。此外,查看这条推文以获取更多详细信息:twitter.com/lukaseder/status/1205046981833482240。
现在,insertedId变量持有数据库生成的标识主键作为Record1<Long>。可以通过fetchOne().value1()或直接通过.fetchOneInto(long.class)获取long值。对于批量插入(多记录插入)也是如此。这次,生成的标识主键存储在Result<Record1<Long>>或List<Long>中:
List<Long> insertedIds = ctx.insertInto(SALE,
SALE.FISCAL_YEAR,SALE.SALE_, SALE.EMPLOYEE_NUMBER,
SALE.FISCAL_MONTH, SALE.REVENUE_GROWTH)
.values(2004, 2311.42, 1370L, 1, 0.0)
.values(2003, 900.21, 1504L, 1, 0.0)
.values(2005, 1232.2, 1166L, 1, 0.0)
.returningResult(SALE.getIdentity().getField())
// or, .returningResult(SALE.SALE_ID)
.collect(intoList());
// or, .fetchInto(Long.class);
对于不能提供标识的特殊情况,jOOQ 允许我们使用方便的lastID()方法:
ctx.insertInto(SALE, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.FISCAL_MONTH,
SALE.REVENUE_GROWTH)
.values(2002, 5411.42, 1504L, 1, 0.0)
.execute();
//meanwhile, a concurrent transaction can sneak a INSERT
var lastId = ctx.lastID();
然而,lastID()方法至少有两个缺点值得我们关注。在一个并发事务环境中(例如,一个 Web 应用),没有保证返回的值属于之前的INSERT语句,因为并发事务可以在我们的INSERT和lastID()调用之间偷偷执行另一个INSERT。在这种情况下,返回的值属于并发事务执行的INSERT语句。此外,在批量插入的情况下,lastID()并不十分有用,因为它只返回最后一个生成的主键(但也许这正是你所需要的)。
如果你正在插入一个可更新的记录,jOOQ 将自动返回生成的标识主键并填充可更新记录的字段,如下所示:
SaleRecord sr = ctx.newRecord(SALE);
sr.setFiscalYear(2021);
...
sr.insert();
// here you can call sr.getSaleId()
在插入后,调用sr.getSaleId()将返回数据库为该记录生成的主键。同样的事情可以通过 jOOQ 的 DAO 在插入 POJO 时完成:
private final SaleRepository saleRepository; // injected DAO
Sale s = new Sale(); // jooq.generated.tables.pojos.Sale
s.setFiscalYear(2020);
...
saleRepository.insert(s);
// here you can call s.getSaleId()
这次,jOOQ 将生成的标识主键设置在插入的 POJO 中。你可以在捆绑的Keys代码示例中找到这些示例。
在可更新记录上抑制主键返回
在前面的章节中,你看到 jOOQ 自动获取并设置可更新记录生成的主键。通过设置 withReturnIdentityOnUpdatableRecord() 标志来抑制此操作。在某些方言中,可以防止数据库往返(lastID() 风格),所以这主要是一个性能特性。默认情况下,此标志为 true,但如果我们显式将其设置为 false,那么 jOOQ 将不再尝试获取生成的主键:
DSLContext derivedCtx = ctx.configuration().derive(
new Settings().withReturnIdentityOnUpdatableRecord(false))
.dsl();
SaleRecord sr = derivedCtx.newRecord(SALE);
sr.setFiscalYear(2021);
...
sr.insert();
这次,调用 sr.getSaleId() 返回 null。
更新可更新记录的主键
作为一种良好的实践,主键无论如何都不应该被更新。但是,我又能怎样评判呢?!
默认情况下,在通过 jOOQ 加载的可更新记录中更改(到非空值)主键后调用 store() 方法会导致执行 INSERT 语句。然而,我们可以通过设置 withUpdatablePrimaryKeys() 标志来强制 jOOQ 生成并执行主键的 UPDATE:
DSLContext derivedCtx = ctx.configuration().derive(
new Settings().withUpdatablePrimaryKeys(true)).dsl();
SaleRecord sr = derivedCtx.selectFrom(SALE)
.where(SALE.SALE_ID.eq(2L))
.fetchSingle();
sr.setSaleId(new_primary_key);
sr.store(); // UPDATE primary key
当然,我们也可以通过显式的 UPDATE 来更新主键,如果你真的必须这样做,那么就选择这种方法而不是 jOOQ 标志:
ctx.update(SALE)
.set(SALE.SALE_ID, sr.getSaleId() + 1)
.where(SALE.SALE_ID.eq(sr.getSaleId()))
.execute();
你可以在捆绑的 Keys 代码中找到这些示例。
使用数据库序列
为了生成序列号,数据库如 PostgreSQL、SQL Server 和 Oracle 依赖于序列。数据库序列独立于表存在 – 它可以与主键和非主键列相关联,它可以自动生成(如 PostgreSQL 的 (BIG)SERIAL),它可以在多个表中使用,它可以有独立的权限,它可以有循环,它可以在自己的事务中增加值以保证使用它的跨事务的唯一性,我们可以通过设置最小值、最大值、增量值和当前值来显式地更改其值,等等。
例如,让我们考虑以下序列(employee_seq),它在我们的 PostgreSQL 架构中定义,用于 employee.employee_number 主键:
CREATE SEQUENCE "employee_seq" START 100000 INCREMENT 10
MINVALUE 100000 MAXVALUE 10000000
OWNED BY "employee"."employee_number";
CREATE TABLE "employee" (
"employee_number" BIGINT NOT NULL,
...
);
employee_seq 序列在插入时不会自动生成序列值,因此应用程序必须显式地操作它。另一方面,sale_seq 序列在插入时会自动生成序列值,其代码块如下(当省略 INSERT 语句中的 SALE_ID 列或使用 DEFAULT 或 DEFAULT VALUES 时,你会得到一个自动值;当用户显式地将 SALE_ID 设置为 NULL 时,将会出现约束违反错误):
CREATE SEQUENCE "sale_seq" START 1000000;
CREATE TABLE "sale" (
"sale_id" BIGINT NOT NULL DEFAULT NEXTVAL ('"sale_seq"'),
...
);
对于每个这样的序列,jOOQ 代码生成器在 Sequences 中生成一个 org.jooq.Sequence 实例(花点时间检查 jooq.generated.Sequences 类)。对于 employee_seq,我们得到以下内容:
public static final Sequence<Long> EMPLOYEE_SEQ =
Internal.createSequence("employee_seq", Public.PUBLIC,
SQLDataType.BIGINT.nullable(false), 100000, 10, 100000,
10000000, false, null);
jOOQ API 提供了几个用于获取序列信息的方法。其中,我们有以下建议方法(你可以在 jOOQ 文档中找到更多信息):
String name = EMPLOYEE_SEQ.getName();
Field<Long> start = EMPLOYEE_SEQ.getStartWith();
Field<Long> min = EMPLOYEE_SEQ.getMinvalue();
Field<Long> max = EMPLOYEE_SEQ.getMaxvalue();
Field<Long> inc = EMPLOYEE_SEQ.getIncrementBy();
除了这些方法之外,我们还有三个在日常生活中非常有用的方法——currval()、nextval()和nextvals()。第一个方法(currval())试图返回序列中的当前值。这可以在一个SELECT语句中获取:
long cr = ctx.fetchValue(EMPLOYEE_SEQ.currval());
long cr = ctx.select(EMPLOYEE_SEQ.currval())
.fetchSingle().value1();
long cr = ctx.select(EMPLOYEE_SEQ.currval())
.fetchSingleInto(Long.class); // or, fetchOneInto()
第二个方法,nextval(),试图返回序列中的下一个值。它可以如下使用:
long nv = ctx.fetchValue(EMPLOYEE_SEQ.nextval());
long nv = ctx.select(EMPLOYEE_SEQ.nextval())
.fetchSingle().value1();
long nv = ctx.select(EMPLOYEE_SEQ.nextval())
.fetchSingleInto(Long.class); // or, fetchOneInto()
以下是一个同时获取当前值和下一个值的SELECT语句:
Record2<Long, Long> vals = ctx.fetchSingle(
EMPLOYEE_SEQ.nextval(), EMPLOYEE_SEQ.currval());
Record2<Long, Long> vals = ctx.select(EMPLOYEE_SEQ.nextval(),
EMPLOYEE_SEQ.currval()) .fetchSingle();
使用序列的一个潜在问题是,在会话中通过选择nextval()对其进行初始化之前,从序列中选择currval()。通常,当你处于这种场景时,你会得到一个明确的错误,指出在这个会话中currval()尚未定义(例如,在 Oracle 中,这是 ORA-08002)。通过执行INSERT或调用nextval()(例如,在之前的SELECT中),你也会初始化currval()。
如果序列可以自动生成值,那么插入新记录的最佳方式是简单地省略主键字段。由于sale_seq可以自动生成值,一个INSERT可以像这样:
ctx.insertInto(SALE, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.FISCAL_MONTH,
SALE.REVENUE_GROWTH)
.values(2005, 1370L, 1282.641, 1, 0.0)
.execute();
数据库将使用sale_seq为SALE_ID字段(SALE的主键)分配一个值。这就像使用与主键关联的任何其他类型的标识符一样。
重要提示
只要你没有特定的情况需要从一个自动生成的序列(例如,从(大)SERIAL)或设置为默认值(例如,NOT NULL DEFAULT NEXTVAL ("'sale_seq'"))中获取特定的序列值,就没有必要显式调用currval()或nextval()方法。只需省略主键字段(或使用序列的任何字段)并让数据库生成它。
然而,如果序列不能自动生成值(例如,employee_seq),那么INSERT语句必须依赖于显式调用nextval()方法:
ctx.insertInto(EMPLOYEE, EMPLOYEE.EMPLOYEE_NUMBER,
EMPLOYEE.LAST_NAME, EMPLOYEE.FIRST_NAME, ...)
.values(EMPLOYEE_SEQ.nextval(),
val("Lionel"), val("Andre"), ...)
.execute();
注意你如何解释和使用currval()和nextval()方法。一旦你通过nextval()(例如,通过SELECT)获取到一个序列值,你就可以在后续的查询(INSERT)中安全地使用它,因为数据库不会将这个值给其他(并发)事务。所以,nextval()可以安全地被多个并发事务使用。另一方面,在currval()的情况下,你必须注意一些方面。查看以下代码:
ctx.insertInto(SALE, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.FISCAL_MONTH,
SALE.REVENUE_GROWTH)
.values(2020, 900.25, 1611L, 1, 0.0)
.execute();
// another transaction can INSERT and currval() is modified
long cr = ctx.fetchValue(SALE_SEQ.currval());
因此,在之前的INSERT和获取当前值的SELECT操作之间,另一个事务可以执行INSERT,并且currval()会被修改/增加(一般来说,另一个事务执行的操作会更新当前值)。这意味着没有保证cr包含我们INSERT的SALE_ID值(SALE_ID和cr可能不同)。如果我们只需要获取我们INSERT的SALE_ID,那么最好的方法就是依赖于returningResult(SALE.SALE_ID),正如你在获取数据库生成的主键部分所看到的。
显然,尝试在随后的 UPDATE、DELETE 等语句中使用获取的 currval() 落在相同的语句之下。例如,不能保证下面的 UPDATE 会更新我们之前的 INSERT:
ctx.update(SALE)
.set(SALE.FISCAL_YEAR, 2005)
.where(SALE.SALE_ID.eq(cr))
.execute();
在并发事务环境中应避免的另一种方法是以下:
ctx.deleteFrom(SALE)
.where(SALE.SALE_ID.eq(ctx.fetchValue(SALE_SEQ.currval())))
.execute();
即使这看起来像是一个单独的查询语句,它也不是。这体现在一个 SELECT 当前值之后的 DELETE。在这两个语句之间,并发事务仍然可以执行一个 INSERT,该 INSERT 会改变当前值(或者,一般而言,任何修改/推进序列并返回新值的操作)。此外,请注意这些类型的查询:
ctx.deleteFrom(SALE)
.where(SALE.SALE_ID.eq(SALE_SEQ.currval()))
.execute();
这使得单个 DELETE 变得可行,如下所示(这是 PostgreSQL 语法):
DELETE FROM "public"."sale" WHERE
"public"."sale"."sale_id" = currval('"public"."sale_seq"')
这次,你肯定是指最新的当前值,无论它是什么。例如,这可能导致删除最新的插入记录(不一定是我们插入的),或者它可能遇到尚未与任何记录关联的当前值。
此外,执行多行插入或批量插入可以利用内联的 nextval() 引用或通过 nextvals() 预取一定数量的值:
List<Long> ids1 = ctx.fetchValues(EMPLOYEE_SEQ.nextvals(10));
List<Long> ids2 = ctx.fetch(EMPLOYEE_SEQ
.nextvals(10)).into(Long.class);
List<Record1<Long>> ids3 = ctx.fetch(
EMPLOYEE_SEQ.nextvals(10));
到目前为止,ids1、ids2 和 ids3 在内存中保存了 10 个可以用于后续查询的值。在我们耗尽这些值之前,没有必要去获取其他值。这样,我们减少了数据库往返的次数。以下是一个多行插入的示例:
for (int i = 0; i < ids.size(); i++) {
ctx.insertInto(EMPLOYEE, EMPLOYEE.EMPLOYEE_NUMBER,
EMPLOYEE.LAST_NAME...)
.values(ids1.get(i), "Lionel", ...)
.execute();
}
预取的值可以用来预先设置 Record 的 ID:
EmployeeRecord er = new EmployeeRecord(ids1.get(0),
// or, ids2.get(0).value1(),
"Lionel", ...);
你可以在捆绑的 Keys 代码中找到这些示例。
插入 SQL Server IDENTITY
在这本书中,我们不是第一次讨论插入 SQL Server IDENTITY 值,但让我们将这一节视为本章的必备内容。问题在于 SQL Server 不允许我们为 PRODUCT 主键的 IDENTITY 字段指定一个显式值:
CREATE TABLE [product] (
[product_id] BIGINT NOT NULL IDENTITY,
...
);
换句话说,以下 INSERT 语句将导致以下错误 – 当 IDENTITY_INSERT 设置为 OFF 时,无法向表 'product' 的标识列插入显式值:
ctx.insertInto(PRODUCT, PRODUCT.PRODUCT_ID,
PRODUCT.PRODUCT_LINE, PRODUCT.CODE,
PRODUCT.PRODUCT_NAME)
.values(5555L, "Classic Cars", 599302L, "Super TX Audi")
.onDuplicateKeyIgnore();
因此,这个错误的解决方案包含在消息中。我们必须将 IDENTITY_INSERT 设置为 ON。然而,这应该在 SQL Server 当前会话上下文 中完成。换句话说,我们必须在同一批中发出 IDENTITY_INSERT 的设置和实际的 INSERT 语句,如下所示:
Query q1 = ctx.query("SET IDENTITY_INSERT [product] ON");
Query q2 = ctx.insertInto(PRODUCT, PRODUCT.PRODUCT_ID,
PRODUCT.PRODUCT_LINE, PRODUCT.CODE, PRODUCT.PRODUCT_NAME)
.values(5555L, "Classic Cars", 599302L, "Super TX Audi")
.onDuplicateKeyIgnore(); // this will lead to a MERGE
Query q3 = ctx.query("SET IDENTITY_INSERT [product] OFF");
ctx.batch(q1, q2, q3).execute();
这次,将值插入 IDENTITY 列没有问题。你可以在捆绑的 Keys(针对 SQL Server)代码中找到这些示例。
获取 Oracle ROWID 伪列
如果你是一个 Oracle 数据库的粉丝,那么不可能没有听说过 ROWID 伪列。然而,作为一个快速提醒,ROWID 伪列与 Oracle 中的每一行相关联,其主要目标是返回行的地址。ROWID 包含的信息可以用来定位特定的行。在 jOOQ 中,我们可以通过rowid()方法引用 ROWID。
例如,以下语句插入一个新的SALE并获取生成的主键和 ROWID:
ctx.insertInto(SALE, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.FISCAL_MONTH,
SALE.REVENUE_GROWTH)
.values(2004, 2311.42, 1370L, 1, 0.0)
.returningResult(SALE.SALE_ID, rowid())
.fetchOne();
rowid()方法返回一个String,表示 ROWID 的值(例如,AAAVO3AABAAAZzBABE)。我们可以使用 ROWID 进行后续查询,例如定位一条记录:
String rowid = ...;
var result = ctx.selectFrom(SALE)
.where(rowid().eq(rowid))
.fetch();
然而,正如 Lukas Eder 所分享的:"ROWIDs 不保证保持稳定,因此客户端永远不应该长时间保留它们(例如,在事务之外)。但它们可以用来在没有主键的表中标识一行(例如,日志表)。"
在捆绑的代码中,Keys(针对 Oracle),你还可以看到在SELECT、UPDATE和DELETE语句中使用rowid()的示例。
比较复合主键
根据定义,复合主键涉及两个或多个列,这些列应唯一标识一条记录。复合主键通常是自然键(即使它由代理键的引用组成),在关系表中通常比代理键更可取:blog.jooq.org/2019/03/26/the-cost-of-useless-surrogate-keys-in-relationship-tables/。这意味着基于复合键的谓词必须包含所有涉及的列。例如,PRODUCTLINE表有一个复合键为(PRODUCT_LINE,CODE),我们可以通过and()方法链式连接复合键的字段来编写一个用于获取特定记录的谓词,如下所示:
var result = ctx.selectFrom(PRODUCTLINE)
.where(PRODUCTLINE.PRODUCT_LINE.eq("Classic Cars")
.and(PRODUCTLINE.CODE.eq(599302L)))
.fetchSingle();
或者,我们可以使用row()(eq()方法不需要显式row()构造函数,所以可以随意使用)来将字段与值分开:
var result = ctx.selectFrom(PRODUCTLINE)
.where(row(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.CODE)
.eq(row("Classic Cars", 599302L)))
.fetchSingle();
使用row()与in()、notIn()等一起使用也是很有用的:
result = ctx.selectFrom(PRODUCTLINE)
.where(row(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.CODE)
.in(row("Classic Cars", 599302L),
row("Trains", 123333L),
row("Motorcycles", 599302L)))
实际上,在这些示例(在Keys中可用)中,你必须确保不要忘记复合键的任何列。对于包含两个以上字段和/或谓词涉及更多相关条件的复合键,这可能会变得很困难,并且难以从视觉上隔离复合键字段。
一个更好的方法是使用嵌入式键。
与嵌入式键一起工作
作为在第七章“类型、转换器和绑定”中引入的嵌入类型的一部分,我们有 jOOQ 嵌入键。嵌入键由 jOOQ 代码生成器实现为 jOOQ org.jooq.EmbeddableRecord接口的实现和一个方便的 POJO 类。嵌入键扩展了org.jooq.EmbeddableRecord接口的默认实现,即org.jooq.impl.EmbeddableRecordImpl。
我们可以为主键和唯一键定义嵌入键。实际上,我们向 jOOQ 指示哪些主键/唯一键应该成为嵌入键,然后 jOOQ 将为每个主键/唯一键以及每个引用这些主键/唯一键的外键生成相应的工件。大致上,嵌入键反映了 Java 类中的主键/唯一键以及对应的外键。
然而,为了使用嵌入键,我们需要以下配置:
// Maven and standalone
<database>
...
<embeddablePrimaryKeys>.*</embeddablePrimaryKeys>
<embeddableUniqueKeys>.*</embeddableUniqueKeys>
</database>
// Gradle
database {
...
embeddablePrimaryKeys = '.*'
embeddableUniqueKeys = '.*'
}
// programmatic
.withEmbeddablePrimaryKeys(".*")
.withEmbeddableUniqueKeys(".*")
很可能,您不会依赖于.*正则表达式,因为您不会希望将所有主键/唯一键转换为嵌入键。例如,您可能只想为组合键使用嵌入键,因此您必须为您的特定情况使用正确的正则表达式。说到组合键,为什么不创建一个嵌入键用于PRODUCTLINE(在上一节中介绍)的组合键呢?
CREATE TABLE [productline] (
[product_line] VARCHAR(50) NOT NULL,
[code] BIGINT NOT NULL,
...
CONSTRAINT [productline_pk]
PRIMARY KEY ([product_line],[code])
);
通过<embeddablePrimaryKeys>productline_pk</embeddablePrimaryKeys>指示 jOOQ 我们感兴趣的(product_line,code)主键,其中productline_pk代表定义我们的组合主键的约束名称(如果您想列出多个约束/主键,则使用|作为分隔符)。
重要提示
作为一条经验法则,始终明确命名您的约束是一个好主意。这样,您就永远不必担心处理供应商特定的生成名称和潜在问题。如果您不确定是否应该始终命名您的约束,那么我建议您阅读这篇有意义的文章:blog.jooq.org/how-to-quickly-rename-all-primary-keys-in-oracle/。
然而,请注意,MySQL 忽略了主键上的约束名称,并将所有默认设置为PRIMARY。在这种情况下,您不能通过约束名称引用组合主键,而必须作为KEY_tablename_PRIMARY来引用。例如,不要使用productline_pk,而应使用KEY_productline_PRIMARY。
到目前为止,jOOQ 已经准备好为这个内嵌键生成类,但让我们再采取一个动作,并自定义这些类的名称。在这个阶段,jOOQ 依赖于默认的匹配策略,所以名称将是ProductlinePkRecord.java和ProductlinePk.java。但是,我们更喜欢EmbeddedProductlinePkRecord.java和EmbeddedProductlinePk.java。正如您已经知道的,每当我们要谈论重命名 jOOQ 相关的内容时,我们可以依赖于配置/程序匹配策略和正则表达式(注意,(?i:...)指令是一个使表达式不区分大小写的指令)。在这种情况下,我们有以下内容:
<strategy>
<matchers>
<embeddables>
<embeddable>
<expression>.*_pk</expression>
<recordClass>
<expression>Embedded_$0_Record</expression>
<transform>PASCAL</transform>
</recordClass>
<pojoClass>
<expression>Embedded_$0</expression>
<transform>PASCAL</transform>
</pojoClass>
</embeddable>
</embeddables>
</matchers>
</strategy>
好的,到目前为止一切顺利!在这个阶段,jOOQ 代码生成器已经准备好在我们的EmbeddedProductlinePkRecord.java和EmbeddedProductlinePk.java中实现内嵌键。此外,jOOQ 在Productline类中生成PRODUCTLINE_PK字段(见jooq.generated.tables.Productline),代表内嵌的主键。
此外,jOOQ 代码生成器搜索引用我们的复合键的外键,并且应该找到以下两个:
CREATE TABLE [product] (
[product_line] VARCHAR(50) DEFAULT NULL,
[code] BIGINT NOT NULL,
...
CONSTRAINT [product_productline_fk]
FOREIGN KEY ([product_line],[code])
REFERENCES [productline] ([product_line],[code])
);
CREATE TABLE [productlinedetail] (
[product_line] VARCHAR(50) NOT NULL,
[code] BIGINT NOT NULL,
...
CONSTRAINT [productlinedetail_productline_fk]
FOREIGN KEY ([product_line],[code])
REFERENCES [productline] ([product_line],[code])
);
对于product_productline_fk和productlinedetail_productline_fk约束(我们的外键),jOOQ 在Product类中生成PRODUCT_PRODUCTLINE_FK字段(见jooq.generated.tables.Product)和PRODUCTLINEDETAIL_PRODUCTLINE_FK字段在Productlinedetail类中(见jooq.generated.tables.Productlinedetail)。
现在,让我们来练习一下!例如,假设我们想要获取PRODUCTLINE的复合主键和创建日期。很可能会这样,如果我们不使用内嵌键,我们的SELECT语句可能如下所示:
var result = ctx.select(PRODUCTLINE.PRODUCT_LINE,
PRODUCTLINE.CODE, PRODUCTLINE.CREATED_ON) ...
我们知道PRODUCT_LINE和CODE构成了我们的复合键。然而,对于不太熟悉我们模式的人来说,依赖PRODUCTLINE_PK内嵌键并编写如下内容会更方便、更安全:
// Result<Record2<EmbeddedProductlinePkRecord, LocalDate>>
var result = ctx.select(PRODUCTLINE.PRODUCTLINE_PK,
PRODUCTLINE.CREATED_ON)...
显然,这更简洁,更具表达力。没有忘记复合键字段或混淆复合键字段与其他字段(这只会增加混淆)的风险,并且我们可以在不修改此代码的情况下添加/删除复合键的列。一旦我们重新运行代码生成器,jOOQ 将相应地调整PRODUCTLINE_PK。
我们可以通过 getter 来访问数据,如下所示:
// '.get(0)' returns the first
// Record2<EmbeddedProductlinePkRecord, LocalDate>,
// while '.value1()' returns the EmbeddedProductlinePkRecord
result.get(0).value1().getProductLine()
result.get(0).value1().getCode()
此外,由于内嵌键还利用了生成的 POJO,我们可以在 POJO 中直接获取复合键。看看这是多么酷:
List<EmbeddedProductlinePk> result =
ctx.select(PRODUCTLINE.PRODUCTLINE_PK)
.from(PRODUCTLINE)
.where(PRODUCTLINE.IMAGE.isNull())
.fetchInto(EmbeddedProductlinePk.class);
EmbeddedProductlinePk POJO 公开 getter 和 setter 来访问内嵌复合键的各个部分。
重要提示
内嵌键是易于发生重叠的内嵌类型。默认情况下,jOOQ 试图优雅地解决每个重叠情况以对我们有利,但当歧义无法澄清时,jOOQ 将记录这些情况,而您需要相应地采取行动。
让我们更进一步,看看其他例子。例如,在某个复合键集合中搜索复合键,可以像下面这样完成:
var result = ctx.selectFrom(PRODUCTLINE)
.where(PRODUCTLINE.PRODUCTLINE_PK.in(
new EmbeddedProductlinePkRecord("Classic Cars", 599302L),
new EmbeddedProductlinePkRecord("Vintage Cars", 223113L)))
.fetch();
或者,可以像下面这样连接PRODUCTLINE和PRODUCT(主键和外键都生成主键记录):
var result = ctx.select(PRODUCTLINE.PRODUCTLINE_PK,
PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_NAME)
.from(PRODUCTLINE)
.join(PRODUCT)
.on(PRODUCTLINE.PRODUCTLINE_PK.eq(
PRODUCT.PRODUCT_PRODUCTLINE_FK))
.fetch();
再次强调,代码更加简洁且更具表现力。然而,更重要的是,没有忘记在连接谓词中复合键列的风险。此外,由于主键和外键都生成主键记录,因此谓词只有在依赖于匹配的主键/外键列时才是有效的。这超出了类型检查的范围,因为没有比较错误字段的风险(例如,不属于复合键但与复合键字段类型相同的字段)。
正如 Lukas Eder 所说:“对于单列键类型,类型检查方面也很有趣。对于可嵌入的类型,列类型变得“语义化”,原本兼容的两个 Fieldtwitter.com/anghelleonard/status/1499751304532533251)
这是一个反思你最喜欢的在 jOOQ 中表达复合键 JOIN 谓词方式的好机会。以下图总结了几个方法,包括简单的and(),使用row(),隐式连接,合成的onKey()和嵌入键:

图 11.1 – 使用复合键的 JOIN 谓词
如何更新/删除/插入一个嵌入键?嗯,这些例子已经说明了一切:
EmbeddedProductlinePkRecord pk = new
EmbeddedProductlinePkRecord("Turbo Jets", 908844L);
ctx.update(PRODUCTLINE)
.set(PRODUCTLINE.TEXT_DESCRIPTION, "Not available")
.where(PRODUCTLINE.PRODUCTLINE_PK.eq(pk))
.execute();
ctx.deleteFrom(PRODUCTLINE)
.where(PRODUCTLINE.PRODUCTLINE_PK.eq(pk))
.execute();
ctx.insertInto(PRODUCTLINE, PRODUCTLINE.PRODUCTLINE_PK,
PRODUCTLINE.TEXT_DESCRIPTION)
.values(pk, "Some cool turbo engines")
.execute();
在EmbeddedCompositeKeys(适用于 SQL Server 和 Oracle)中练习这些例子。或者,如果你更喜欢从嵌入键开始,用于简单的主键,那么你可以查看EmbeddedSimpleKeys应用程序(适用于 SQL Server 和 Oracle)。接下来,让我们谈谈 jOOQ 合成对象。
使用 jOOQ 合成对象
jOOQ 合成对象是 3.14 版本中引入的一个强大而令人兴奋的功能,它通过数据库(可更新)视图、你不得不想要更改的数据库以及一些缺失部分的遗留数据库完全展示了其可用性。通过缺失部分,我们指的是不存在或存在但未启用或由数据库(以及不在数据库元数据中)报告的标识、主键、唯一键和外键。jOOQ 代码生成器可以通过生成模拟这些缺失部分的合成对象来处理这一方面。让我们采用示例学习技术来看看合成对象是如何工作的。
合成主键/外键
让我们考虑以下两个数据库视图(在 PostgreSQL 中):
CREATE OR REPLACE VIEW "customer_master" AS
SELECT "customerdetail"."city",
"customerdetail"."country",
"customerdetail"."state",
"customerdetail"."postal_code",
...
FROM "customer"
JOIN "customerdetail"
ON "customerdetail"."customer_number" =
"customer"."customer_number"
WHERE "customer"."first_buy_date" IS NOT NULL;
CREATE OR REPLACE VIEW "office_master" AS
SELECT "office"."city",
"office"."country",
"office"."state",
"office"."postal_code",
...
FROM "office"
WHERE "office"."city" IS NOT NULL;
正如常规表的情况一样,jOOQ 为这些视图生成相应的记录、表和 POJOs,因此你将在jooq.generated.tables.records中拥有CustomerMasterRecord(一个不可更新的记录,因为视图是不可更新的)和OfficeMasterRecord(一个可更新的记录,因为视图是可更新的),分别在jooq.generated.tables和jooq.generated.tables.pojos中拥有CustomerMaster和OfficeMaster。
接下来,让我们宽容地假设一个三元组(country、state和city)唯一地标识了一个客户和一个办公室,并且我们想要找到与办公室在同一地区的客户。为此,我们可以编写LEFT JOIN,如下所示:
ctx.select(CUSTOMER_MASTER.CUSTOMER_NAME,
CUSTOMER_MASTER.CREDIT_LIMIT,
CUSTOMER_MASTER.CITY.as("customer_city"),
OFFICE_MASTER.CITY.as("office_city"),
OFFICE_MASTER.PHONE)
.from(CUSTOMER_MASTER)
.leftOuterJoin(OFFICE_MASTER)
.on(row(CUSTOMER_MASTER.COUNTRY, CUSTOMER_MASTER.STATE,
CUSTOMER_MASTER.CITY)
.eq(row(OFFICE_MASTER.COUNTRY, OFFICE_MASTER.STATE,
OFFICE_MASTER.CITY)))
.orderBy(CUSTOMER_MASTER.CUSTOMER_NAME)
.fetch();
看看JOIN语句的谓词!它很冗长且容易出错。此外,如果我们修改(例如,重命名或删除)涉及此谓词的任何列,那么我们还得调整这个谓词。然而,我们无能为力,因为数据库视图不支持主键/外键,对吧?实际上,这正是合成键登场的地方。如果 jOOQ 能够为我们提供OFFICE_MASTER的合成复合主键和引用OFFICE_MASTER合成主键的合成外键,那么我们就可以简化并减少JOIN中的错误风险。实际上,我们可以将JOIN表达为隐式JOIN或通过onKey(),就像常规表的情况一样。
然而,请记住我们所说的宽容地假设唯一性。请注意,我们甚至不需要对自然键(country、state和city)假设唯一性。合成主键/唯一键(PK/UK)甚至可以用来为实际上不是候选键或甚至唯一的事物启用一些酷炫的功能。例如,可能有数百份基于这种“位置关系”的计算报告,而标准化是不可能的,因为这是一个数据仓库,等等。
进一步来说,jOOQ 的合成键在配置级别上形成。对于 Maven 和独立配置,我们需要以下直观的代码片段,它定义了office_master_pk合成复合主键和office_master_fk合成外键(你应该可以通过简单地跟随标签的名称及其在先前数据库视图中的内容来理解这段代码):
<database>
...
<syntheticObjects>
<primaryKeys>
<primaryKey>
<name>office_master_pk</name>
<tables>office_master</tables>
<fields>
<field>country</field>
<field>state</field>
<field>city</field>
</fields>
</primaryKey>
</primaryKeys>
<foreignKeys>
<foreignKey>
<name>office_master_fk</name>
<tables>customer_master</tables>
<fields>
<field>country</field>
<field>state</field>
<field>city</field>
</fields>
<referencedTable>office_master</referencedTable>
<referencedFields>
<field>country</field>
<field>state</field>
<field>city</field>
</referencedFields>
</foreignKey>
</foreignKeys>
</syntheticObjects>
</database>
你可以在 jOOQ 手册中找到关于 Gradle 和编程方法的指导(在 jOOQ 风格中,这种方法同样直观)。
现在,在运行 jOOQ 代码生成器之后,我们的JOIN可以利用生成的合成键,并通过在第六章中引入的合成onKey()来简化,该章节是处理不同类型的 JOIN 语句。因此,现在我们可以这样写:
ctx.select(CUSTOMER_MASTER.CUSTOMER_NAME,
CUSTOMER_MASTER.CREDIT_LIMIT,
CUSTOMER_MASTER.CITY.as("customer_city"),
OFFICE_MASTER.CITY.as("office_city"),
OFFICE_MASTER.PHONE)
.from(CUSTOMER_MASTER)
.leftOuterJoin(OFFICE_MASTER)
.onKey()
.orderBy(CUSTOMER_MASTER.CUSTOMER_NAME)
.fetch();
与之前的方法相比,这更简洁,更不容易出错,并且对合成键涉及的列的后续修改具有鲁棒性。当然,你可以使用onKey()来编写INNER JOIN和RIGHT JOIN语句等。然而,没有合成键,使用onKey()会导致DataAccessException - 在表["classicmodels"."customer_master"]和["classicmodels"."office_master"]之间没有找到匹配的键。
即使onKey()工作得很好,你很可能会发现合成onKey(),这可能导致复杂JOIN图(甚至简单图)中的歧义。隐式连接总是无歧义的。
所以,坚持使用LEFT JOIN,之前的JOIN可以通过采用隐式JOIN来简化并进一步增强:
ctx.select(CUSTOMER_MASTER.CUSTOMER_NAME,
CUSTOMER_MASTER.CREDIT_LIMIT,
CUSTOMER_MASTER.CITY.as("customer_city"),
CUSTOMER_MASTER.officeMaster().CITY.as("office_city"),
CUSTOMER_MASTER.officeMaster().PHONE)
.from(CUSTOMER_MASTER)
.orderBy(CUSTOMER_MASTER.CUSTOMER_NAME)
.fetch();
太酷了!连接谓词中没有显式列,我们可以修改复合键而没有任何风险!一旦我们运行 jOOQ 代码生成器来反映这些更改,这段代码就会自动工作。
然而,这里隐式连接的示例可能会导致一些奇怪的现象。由于这是一个合成外键,而合成主键实际上并不真正唯一(我们只是宽容地假设了唯一性),投影隐式连接路径意味着我们可能仅仅从投影中就得到笛卡尔积,这在 SQL 中是非常令人惊讶的。投影不应该影响结果集的基数,但在这里我们就是这样...或许这是一个探索UNIQUE()谓词的好机会,以检查它们的“候选”键是否真正唯一:www.jooq.org/doc/latest/manual/sql-building/conditional-expressions/unique-predicate/。
你可以在SyntheticPkKeysImplicitJoin中练习这个示例。
合成键的嵌入键
接下来,让我们假设我们想要根据给定的country、state和city三元组从OFFICE_MASTER表中获取一些数据。在这个阶段,我们可以这样写:
ctx.select(OFFICE_MASTER.OFFICE_CODE, OFFICE_MASTER.PHONE)
.from(OFFICE_MASTER)
.where(row(OFFICE_MASTER.COUNTRY, OFFICE_MASTER.STATE,
OFFICE_MASTER.CITY).in(
row("USA", "MA", "Boston"),
row("USA", "CA", "San Francisco")))
.fetch();
然而,我们知道(country、state、city)实际上是我们的合成键。这意味着如果我们为这个合成键定义一个嵌入键,那么我们应该像在Working with embedded keys部分中看到的那样利用嵌入键。由于合成键的名称是office_master_pk,嵌入键恢复到这个:
<embeddablePrimaryKeys>
office_master_pk
</embeddablePrimaryKeys>
重新运行 jOOQ 代码生成器以生成与这个嵌入键OfficeMasterPkRecord和OfficeMasterPk POJO 对应的 jOOQ 工件。这次,我们可以重写我们的查询,如下所示:
ctx.select(OFFICE_MASTER.OFFICE_CODE, OFFICE_MASTER.PHONE)
.from(OFFICE_MASTER)
.where(OFFICE_MASTER.OFFICE_MASTER_PK.in(
new OfficeMasterPkRecord("USA", "MA", "Boston"),
new OfficeMasterPkRecord("USA", "CA", "San Francisco")))
.fetch();
或者,也许我们想要获取OfficeMasterPk POJO 中嵌入的键值:
List<OfficeMasterPk> result =
ctx.select(OFFICE_MASTER.OFFICE_MASTER_PK)
.from(OFFICE_MASTER)
.where(OFFICE_MASTER.OFFICE_CODE.eq("1"))
.fetchInto(OfficeMasterPk.class);
那么使用显式OFFICE_MASTER_PK和OFFICE_MASTER_FK的JOIN怎么样?
ctx.select(CUSTOMER_MASTER.CUSTOMER_NAME, ...)
.from(CUSTOMER_MASTER)
.innerJoin(OFFICE_MASTER)
.on(OFFICE_MASTER.OFFICE_MASTER_PK
.eq(CUSTOMER_MASTER.OFFICE_MASTER_FK))
.orderBy(CUSTOMER_MASTER.CUSTOMER_NAME)
.fetch();
或者,也许是一个基于嵌入键的更新操作:
ctx.update(OFFICE_MASTER)
.set(OFFICE_MASTER.PHONE, "+16179821809")
.where(OFFICE_MASTER.OFFICE_MASTER_PK.eq(
new OfficeMasterPkRecord("USA", "MA", "Boston")))
.execute();
你可以在EmbeddedSyntheticKeys for PostgreSQL 中练习这些示例。
使用导航方法
此外,如果我们检查生成的 jooq.generated.Keys,我们会注意到为 OFFICE_MASTER 和 CUSTOMER_MASTER 生成的以下键:
UniqueKey<OfficeMasterRecord> OFFICE_MASTER_PK = ...
ForeignKey<CustomerMasterRecord, OfficeMasterRecord>
CUSTOMER_MASTER__OFFICE_MASTER_FK = ...
这些键与 jOOQ 导航方法(fetchParent()、fetchChildren()、fetchChild() 等)结合使用非常有用。这些方法在 第九章 中介绍,CRUD、事务和锁定,以下是使用它们导航我们的视图的两个示例:
CustomerMasterRecord cmr = ctx.selectFrom(CUSTOMER_MASTER)
.where(CUSTOMER_MASTER.CUSTOMER_NAME
.eq("Classic Legends Inc.")).fetchSingle();
OfficeMasterRecord parent = cmr.fetchParent(
Keys.CUSTOMER_MASTER__OFFICE_MASTER_FK);
List<CustomerMasterRecord> children =
parent.fetchChildren(Keys.CUSTOMER_MASTER__OFFICE_MASTER_FK);
您可以在 SyntheticPkKeysNavigation 中练习这些示例,这是 PostgreSQL 的。
合成唯一键
在上一节中,我们使用了一个基于三元组 country、state 和 city 的合成合成主键。然而,如果我们仔细观察,我们会注意到这两个视图都选择了 postal_code。由于我们没有在同一城市中的两个办事处,我们可以考虑 postal_code(在 office 表中具有 CONSTRAINT "office_postal_code_uk" UNIQUE ("postal_code"))是 office_master 的唯一键(当然,在现实中,你必须注意这样的假设;也许最好的表示地址的方式是通过 BLOB,但让我们继续使用我们已有的)。这意味着我们也可以使用合成唯一键。只需简单地将 <primaryKeys/> 标签替换为 <uniqueKeys/> 标签,就像这里所示,我们就可以将 postal_code 设置为合成唯一键:
<syntheticObjects>
<uniqueKeys>
<uniqueKey>
<name>office_master_uk</name>
<tables>office_master</tables>
<fields>
<field>postal_code</field>
</fields>
</uniqueKey>
</uniqueKeys>
<foreignKeys>
<foreignKey>
<name>customer_office_master_fk</name>
<tables>customer_master</tables>
<fields>
<field>postal_code</field>
</fields>
<referencedTable>office_master</referencedTable>
<referencedFields>
<field>postal_code</field>
</referencedFields>
</foreignKey>
</foreignKeys>
</syntheticObjects>
好消息是,依赖于合成键的我们的 JOIN 语句将直接工作,即使我们从复合合成主键切换到简单合成唯一键。为 PostgreSQL 提供的捆绑代码是 SyntheticUniqueKeysImplicitJoin。
合成标识符
如您之前所见,jOOQ 在执行插入操作后可以检索标识符主键(通过 insertInto()…returningResult(pk) 或插入可更新的记录)。但是,并非所有标识符列都必须是主键。例如,我们的 PostgreSQL 中的 PRODUCT 表有两个标识符列——一个是主键(PRODUCT_ID),而另一个只是一个简单的标识符列(PRODUCT_UID):
CREATE TABLE "product" (
"product_id" BIGINT
NOT NULL DEFAULT NEXTVAL ('"product_seq"'),
…
"product_id" BIGINT GENERATED BY DEFAULT AS IDENTITY
(START WITH 10 INCREMENT BY 10),
CONSTRAINT "product"pk" PRIMARY KEY ("product_id"),
...
) ;
通过 insertInto() … returningResult(pk) 同时检索两个标识符非常简单:
var result = ctx.insertInto(PRODUCT)
.set(PRODUCT.PRODUCT_LIN", "Vintage Cars")
.set(PRODUCT.CODE, 223113L)
.set(PRODUCT.PRODUCT_NAME, "Rolls-Royce Dawn Drophead")
.returningResult(PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_UID)
.fetch();
result.get(0).value1(); // valid primary key (PRODUCT_ID)
result.get(0).value2(); // valid identity key (PRODUCT_UID)
这并不令人惊讶,因为 returningResult() 指示 jOOQ 返回所有列出的列。然而,插入记录代表了一个更有趣的情况:
ProductRecord pr = ctx.newRecord(PRODUCT);
pr.setProductLine("Classic Cars");
pr.setCode(599302L);
pr.setProductName("1967 Chevrolet Camaro RS");
pr.insert();
pr.getProductId(); // valid primary key (PRODUCT_ID)
pr.getProductUid(); // valid identity key (PRODUCT_UID) WOW!
真是酷!除了标识符主键之外,jOOQ 还已将数据库生成的 PRODUCT_UID 填充到记录中。因此,只要数据库报告某一列是标识符,jOOQ 就可以检测到它并相应地处理。
好的,接下来让我们关注我们的 Oracle 架构,它定义了 PRODUCT 表,如下所示:
CREATE TABLE product (
product_id NUMBER(10) DEFAULT product_seq.nextval
NOT NULL,
...
product_uid NUMBER(10) DEFAULT product_uid_seq.nextval
NOT NULL,
CONSTRAINT product_pk PRIMARY KEY (product_id),
...
);
CREATE SEQUENCE product_seq START WITH 1000000 INCREMENT BY 1;
CREATE SEQUENCE product_uid_seq START WITH 10 INCREMENT BY 10;
在这个场景中,insertInto() … returningResult() 的工作方式符合预期,但在插入一个 ProductRecord 之后,我们只得到了标识主键(PRODUCT_ID),而调用 getProductUid() 将返回 null。换句话说,jOOQ 只检测到 PRODUCT_ID 是主键列,而 PRODUCT_UID 并没有被数据库报告为标识列。然而,正是在这里,jOOQ 的合成标识符发挥了救星的作用。合成标识符允许我们配置 jOOQ 将那些没有被数据库报告为标识列的列视为正式标识符。在这个特定的情况下,PRODUCT_UID 就属于这一范畴,因此以下是针对 Maven(以及独立版本)的 jOOQ 预期配置:
<syntheticObjects>
<identities>
<identity>
<tables>product</tables>
<fields>product_uid</fields>
</identity>
</identities>
</syntheticObjects>
如果你有多张表/标识符,那么列出它们,用 | 作为正则表达式分隔。这次,在运行代码生成器并插入一个新的 ProductRecord 之后,jOOQ 会获取 PRODUCT_ID(通过 getProductId() 检查)和 PRODUCT_UID(通过 getProductUid() 检查,它应该返回一个有效的整数)。此外,这对于使用序列和触发器模拟正式标识符的 Oracle 版本也适用(在 Oracle 12c 之前)。因此,jOOQ 的另一个酷炫特性已经被揭示。
包含的代码示例是 DetectIdentity(用于 PostgreSQL)和 SyntheticIdentity(用于 Oracle)。
钩子计算列
一个 计算列 是一个不能写入的列。它的值是从一个给定的 表达式 计算得出的。当一个列在读取时进行计算(例如,在 SELECT 语句中)时,它被称为 VIRTUAL 列(在 DDL 中,这样的列大致表示为 … GENERATED ALWAYS AS <expression> VIRTUAL)。通常,VIRTUAL 列在数据库模式中不存在/出现。另一方面,一个在写入时进行计算的列(例如,在 INSERT、UPDATE、MERGE 语句中)被称为 STORED 列(在 DDL 中,一些常见的语法是 ... GENERATED ALWAYS AS <expression> STORED)。这样的列存在于/出现在你的数据库模式中。
服务器端计算列
在这个上下文中,jOOQ 3.16 增加了 服务器端计算列 的支持。jOOQ 的代码生成器能够检测服务器端计算列并将它们标记为 只读(www.jooq.org/doc/latest/manual/co[de-generation/codegen-advanced/codegen-config-database/codegen-database-readonly-columns/)).换句话说,为了方便起见,这些列自动排除在 DML 语句之外,只出现在 SELECT 语句中。然而,jOOQ 允许我们通过在 www.jooq.org/doc/latest/manual/sql-building/column-expressions/readonly-columns/ 可用的设置中微调只读列。此外,jOOQ 支持 合成只读列,如果通过 <readonlyColumns/>、<readonlyColumn/> 标签(Maven)进行配置,jOOQ 会识别它们。你可以在 jOOQ 文档中探索这个非常有趣的主题,www.jooq.org/doc/latest/manual/code-generation/codegen-advanced/codegen-config-database/codegen-database-synthetic-objects/codegen-database-synthetic-readonly-columns/,但现在,让我们回到计算列的主题。
因此,并非所有方言都支持服务器端计算列或基于标量子查询(即使是关联子查询)的表达式,或者隐式连接。jOOQ 3.17 带来一个强大的功能,覆盖了这些限制,这个功能被称为 客户端计算列。
客户端计算列
查看以下客户端计算列(类似于 VIRTUAL)的配置:
<database>
…
<!-- Prepare the synthetic keys -->
<syntheticObjects>
<columns>
<column>
<name>REFUND_AMOUNT</name>
<tables>BANK_TRANSACTION</tables>
<type>DECIMAL(10,2)</type>
</column>
</columns>
</syntheticObjects>
<!-- Now tell the code generator
how to compute the values -->
<forcedTypes>
<forcedType>
<generator>
ctx -> payment().INVOICE_AMOUNT.minus(
DSL.sum(TRANSFER_AMOUNT))
</generator>
<includeExpression>REFUND_AMOUNT</includeExpression>
</forcedType>
</forcedTypes>
</database>
因为强制类型匹配一个合成列(REFUND_AMOUNT),jOOQ 语义表示一个 VIRTUAL 计算列。因此,该列在数据库模式中不存在,但计算(这里是一个隐式连接,但关联子查询也是一个支持选项)将自动出现在包含此列的所有 SELECT 语句中。在为 SQL Server 提供的捆绑代码中,SyntheticComputedColumns,你可以看到一个使用虚拟列 BANK_TRANSACTION.REFUND_AMOUNT 的查询示例。
现在,看看这个:
<database>
<!-- Tell the code generator how to
compute the values for an existing column -->
<forcedTypes>
<forcedType>
<generator>
ctx -> DSL.concat(OFFICE.COUNTRY, DSL.inline(“, “),
OFFICE.STATE, DSL.inline(“, “), OFFICE.CITY)
</generator>
<includeExpression>
OFFICE.ADDRESS_LINE_FIRST
</includeExpression>
</forcedType>
</forcedTypes>
</database>
这次,强制类型与实际列(OFFICE.ADDRESS_LINE_FIRST)匹配,因此 jOOQ 应用了 STORED 计算列的语义。换句话说,DML 语句将被转换以反映值的正确计算,该值将被写入您的模式。您可以在捆绑的代码中查看一个示例,StoredComputedColumns,适用于 SQL Server。此外,如果您能抽出时间,请阅读这篇优秀的文章:blog.jooq.org/create-dynamic-views-with-jooq-3-17s-new-virtual-client-side-computed-columns/。
覆盖主键
让我们考虑以下模式片段(来自 PostgreSQL):
CREATE TABLE "customer" (
"customer_number" BIGINT NOT NULL
DEFAULT NEXTVAL ('"customer_seq"'),
"customer_name" VARCHAR(50) NOT NULL,
...
CONSTRAINT "customer_pk" PRIMARY KEY ("customer_number"),
CONSTRAINT "customer_name_uk" UNIQUE ("customer_name")
...
);
CREATE TABLE "department" (
"department_id" SERIAL NOT NULL,
"code" INT NOT NULL,
...
CONSTRAINT "department_pk" PRIMARY KEY ("department_id"),
CONSTRAINT "department_code_uk" UNIQUE ("code")
...
);
以下是一个更新 CUSTOMER 的示例:
CustomerRecord cr = ctx.selectFrom(CUSTOMER)
.where(CUSTOMER.CUSTOMER_NAME.eq("Mini Gifts ..."))
.fetchSingle();
cr.setPhone("4159009544");
cr.store();
这里是更新 DEPARTMENT 的一个示例:
DepartmentRecord dr = ctx.selectFrom(DEPARTMENT)
.where(DEPARTMENT.DEPARTMENT_ID.eq(1))
.fetchSingle();
dr.setTopic(new String[] {"promotion", "market", "research"});
dr.store();
从 第九章,CRUD、事务和锁定,我们知道 store() 的工作原理;因此,我们知道生成的 SQL 依赖于 CUSTOMER 主键和 DEPARTMENT 主键(update()、merge()、delete() 和 refresh() 的行为相同)。例如,cr.store() 执行以下 UPDATE:
UPDATE "public"."customer" SET "phone" = ?
WHERE "public"."customer"."customer_number" = ?
由于 CUSTOMER_NUMBER 是 CUSTOMER 的主键,jOOQ 使用它来为这个 UPDATE 添加谓词。
另一方面,dr.store() 执行以下 UPDATE:
UPDATE "public"."department" SET "topic" = ?::text[]
WHERE ("public"."department"."name" = ?
AND "public"."department"."phone" = ?)
这里看起来有些不对劲,因为我们的模式显示 DEPARTMENT 的主键是 DEPARTMENT_ID,那么为什么 jOOQ 在这里使用包含 DEPARTMENT_NAME 和 DEPARTMENT_PHONE 的组合谓词呢?这可能会让人困惑,但答案相当简单。我们实际上定义了一个合成主键(department_name 和 department_phone),我们在这里揭示它:
<syntheticObjects>
<primaryKeys>
<primaryKey>
<name>synthetic_department_pk</name>
<tables>department</tables>
<fields>
<field>name</field>
<field>phone</field>
</fields>
</primaryKey>
</primaryKeys>
</syntheticObjects>
太酷了!所以,jOOQ 使用了合成键来代替模式主键。我们可以这样说,我们用合成键覆盖了模式的主键。
让我们再来一次!例如,假设我们想指示 jOOQ 使用 customer_name 唯一键为 cr.store() 和 code 唯一键为 dr.store()。这意味着我们需要以下配置:
<syntheticObjects>
<primaryKeys>
<primaryKey>
<name>synthetic_customer_name</name>
<tables>customer</tables>
<fields>
<field>customer_name</field>
</fields>
</primaryKey>
<primaryKey>
<name>synthetic_department_code</name>
<tables>department</tables>
<fields>
<field>code</field>
</fields>
</primaryKey>
</primaryKeys>
</syntheticObjects>
此配置覆盖了模式默认值,生成的 SQL 变为以下内容:
UPDATE "public"."customer" SET "phone" = ?
WHERE "public"."customer"."customer_name" = ?
UPDATE "public"."department" SET "topic" = ?::text[]
WHERE "public"."department"."code" = ?
太棒了,不是吗?!完整的示例命名为 OverridePkKeys,适用于 PostgreSQL。
摘要
希望您喜欢这个关于 jOOQ 键的简短但全面的章节。本章的示例涵盖了处理不同类型键的流行方面,从唯一/主键到 jOOQ 内嵌和合成键。我真心希望您不要止步于这些示例,而是对深入了解这些惊人的 jOOQ 功能感到好奇——例如,一个值得您关注的有趣主题是只读列:www.jooq.org/doc/dev/manual/sql-building/column-expressions/readonly-columns/。
在下一章,我们将探讨分页和动态查询。
第十二章:分页和动态查询
在本章中,我们将讨论 jOOQ 中的分页和动态查询,这两个主题在广泛的应用中协同工作,用于分页和过滤产品、项目、图片、帖子、文章等列表。
本章将涵盖以下主题:
-
jOOQ 偏移量分页
-
jOOQ 键集分页
-
编写动态查询
-
无限滚动和动态筛选
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter12。
偏移量和键集分页
偏移量和键集分页(或如马克斯·温安德所说的“查找”)是两种在从数据库中获取数据时进行分页的知名技术。偏移量分页相当流行,因为 Spring Boot(更确切地说,Spring Data Commons)提供了两种默认实现,通过 Page 和 Slice API。因此,从生产力的角度来看,依赖这些实现非常方便。然而,从性能的角度来看,随着项目的演变和数据量的积累,依赖偏移量分页可能会导致严重的性能下降。不过,正如你很快就会看到的,jOOQ 可以帮助你改善这种情况。
相反,键集分页是一种保持高性能的技术,比偏移量分页更快、更稳定。键集分页在分页大型数据集和无限滚动方面表现尤为出色,同时其性能几乎与偏移量分页相同,尤其是在相对较小的数据集上。然而,你能保证数据量不会随着时间的推移而增长(有时增长得相当快)吗?如果可以,那么使用偏移量分页应该是可以的。否则,最好从一开始就防止这种已知的性能问题,并依赖键集分页。不要认为这是过早优化;将其视为根据你正在建模的业务案例做出正确决策的能力。而且,正如你很快就会看到的,jOOQ 使得键集分页变得轻而易举。
偏移量和键集索引扫描
处理偏移量分页意味着你可以忽略在达到期望偏移量之前丢弃 n 条记录所引起的性能惩罚。较大的 n 会导致显著的性能惩罚,这同样影响了 Page 和 Slice API。另一个惩罚是需要额外的 SELECT COUNT 来计算记录总数。这个额外的 SELECT COUNT 仅针对 Page API,因此不会影响 Slice API。基本上,这是 Page 和 Slice API 之间的主要区别;前者包含记录总数(用于计算总页数),而后者只能判断是否至少还有一页可用或这是最后一页。
Lukas Eder 在这里有一个非常棒的观察:“我倾向于这样思考的另一种方式是:能够跳转到第 2712 页的商业价值是什么?那个页码甚至意味着什么?难道不应该使用更好的过滤器来细化搜索吗?另一方面,从任何页面跳转到下一页是一个非常常见的需求。”*
偏移量索引扫描将从开始到指定的偏移量遍历索引范围。基本上,偏移量表示在将记录包含到结果集中之前必须跳过的记录数。因此,偏移量方法将遍历已经显示的记录,如下面的图所示:

图 12.1 – 偏移量和键集分页中的索引扫描
另一方面,键集分页中的索引扫描将仅遍历所需值,从最后一个前一个值开始(它跳过直到最后一个之前获取的值)。在键集中,性能相对于表记录的增加保持大致恒定。
重要提示
在 USE THE INDEX, LUKE! 网站上提到了一个重要的参考和反对使用偏移量分页的论点(use-the-index-luke.com/no-offset)。我强烈建议你花些时间观看 Markus Winand 的这个精彩演示(www.slideshare.net/MarkusWinand/p2d2-pagination-done-the-postgresql-way?ref=https://use-the-index-luke.com/no-offset),它涵盖了调整分页-SQL 的重要主题,例如在偏移量和键集分页中使用索引和行值(在 PostgreSQL 中受支持)。
好的,在总结了偏移量与键集分页之后,让我们看看 jOOQ 如何模拟甚至改进默认的 Spring Boot 偏移量分页实现。
jOOQ 偏移量分页
Spring Boot 的偏移量分页实现可以通过 LIMIT … OFFSET(或 OFFSET … FETCH)和 SELECT COUNT 轻松地塑形。例如,如果我们假设客户端通过 page 参数给我们一个页码(size 是要在 page 上显示的产品数量),那么下面的 jOOQ 查询模拟了 PRODUCT 表的默认 Spring Boot 分页行为:
long total = ctx.fetchCount(PRODUCT);
List<Product> result = ctx.selectFrom(PRODUCT)
.orderBy(PRODUCT.PRODUCT_ID)
.limit(size)
.offset(size * page)
.fetchInto(Product.class);
如果客户端(例如,浏览器)期望作为响应返回经典 Page<Product> (org.springframework.data.domain.Page) 的序列化,那么你可以简单地生成它,如下所示:
Page<Product> pageOfProduct = new PageImpl(result,
PageRequest.of(page, size, Sort.by(Sort.Direction.ASC,
PRODUCT.PRODUCT_ID.getName())), total);
然而,我们不必执行两个 SELECT 语句,我们可以使用 COUNT() 窗口函数以单个 SELECT 获取相同的结果:
Map<Integer, List<Product>> result = ctx.select(
PRODUCT.asterisk(), count().over().as("total"))
.from(PRODUCT)
.orderBy(PRODUCT.PRODUCT_ID)
.limit(size)
.offset(size * page)
.fetchGroups(field("total", Integer.class), Product.class);
这已经比默认的 Spring Boot 实现要好。当你将这个 Map<Integer, List<Product>> 返回给客户端时,你也可以返回一个 Page<Product>:
Page<Product> pageOfProduct
= new PageImpl(result.values().iterator().next(),
PageRequest.of(page, size, Sort.by(Sort.Direction.ASC,
PRODUCT.PRODUCT_ID.getName())),
result.entrySet().iterator().next().getKey());
很可能,你更喜欢Page,因为它包含一系列元数据,例如如果没有分页的总记录数(totalElements),当前页码(pageNumber),实际页面大小(pageSize),返回行的实际偏移量(offset),以及我们是否在最后一页(last)。你可以在捆绑的代码(PaginationCountOver)中找到之前的例子。
然而,正如 Lukas Eder 在这篇文章中强调的(blog.jooq.org/2021/03/11/calculating-pagination-metadata-without-extra-roundtrips-in-sql/),所有这些元数据都可以在一个 SQL 查询中获取;因此,没有必要创建一个Page对象来使它们可用。在捆绑的代码(PaginationMetadata)中,你可以通过 REST 控制器练习 Lukas 的动态查询。
jOOQ 键集分页
键集(或 seek)分页在 Spring Boot 中没有默认实现,但这不应该阻止你使用它。只需从选择一个用作最新访问记录/行的表列(例如,id 列)开始,并在WHERE和ORDER BY子句中使用这个列。依赖于 ID 列的惯用用法如下(多列排序遵循相同的概念):
SELECT ... FROM ...
WHERE id < {last_seen_id}
ORDER BY id DESC
LIMIT {how_many_rows_to_fetch}
SELECT ... FROM ...
WHERE id > {last_seen_id}
ORDER BY id ASC
LIMIT {how_many_rows_to_fetch}
或者,像这样:
SELECT ... FROM ...
WHERE ... AND id < {last_seen_id}
ORDER BY id DESC
LIMIT {how_many_rows_to_fetch}
SELECT ... FROM ...
WHERE ... AND id > {last_seen_id}
ORDER BY id ASC
LIMIT {how_many_rows_to_fetch}
根据迄今为止获得的经验,在 jOOQ 中表达这些查询应该轻而易举。例如,让我们通过PRODUCT_ID将第一个惯用用法应用到PRODUCT表上:
List<Product> result = ctx.selectFrom(PRODUCT)
.where(PRODUCT.PRODUCT_ID.lt(productId))
.orderBy(PRODUCT.PRODUCT_ID.desc())
.limit(size)
.fetchInto(Product.class);
在 MySQL 中,渲染的 SQL 如下(其中productId = 20和size = 5):
SELECT `classicmodels`.`product`.`product_id`,
`classicmodels`.`product`.`product_name`,
...
FROM `classicmodels`.`product`
WHERE `classicmodels`.`product`.`product_id` < 20
ORDER BY `classicmodels`.`product`.`product_id` DESC
LIMIT 5
这很简单!你可以在KeysetPagination中练习这个案例。
然而,如果WHERE子句变得更加复杂,键集分页就会变得有点棘手。幸运的是,jOOQ 通过一个名为SEEK的合成子句帮助我们避免了这种情况。让我们深入探讨它!
jOOQ 的 SEEK 子句
jOOQ 的合成SEEK子句简化了键集分页的实现。其主要优点之一是SEEK子句是类型安全的,并且能够生成/模拟正确的/预期的WHERE子句(包括行值表达式的模拟)。
例如,前面的键集分页示例可以使用SEEK子句来表示,如下所示(productId由客户提供):
List<Product> result = ctx.selectFrom(PRODUCT)
.orderBy(PRODUCT.PRODUCT_ID)
.seek(productId)
.limit(size)
.fetchInto(Product.class);
注意,这里没有显式的WHERE子句。jOOQ 会根据seek()参数为我们生成它。虽然这个例子可能看起来并不那么令人印象深刻,但让我们考虑另一个例子。这次,让我们使用员工的办公代码和薪水来分页EMPLOYEE:
List<Employee> result = ctx.selectFrom(EMPLOYEE)
.orderBy(EMPLOYEE.OFFICE_CODE, EMPLOYEE.SALARY.desc())
.seek(officeCode, salary)
.limit(size)
.fetchInto(Employee.class);
officeCode和salary都由客户提供,并会落在以下生成的 SQL 示例中(其中officeCode = 1,salary = 75000,size = 10):
SELECT `classicmodels`.`employee`.`employee_number`,
...
FROM `classicmodels`.`employee`
WHERE (`classicmodels`.`employee`.`office_code` > '1'
OR (`classicmodels`.`employee`.`office_code` = '1'
AND `classicmodels`.`employee`.`salary` < 75000))
ORDER BY `classicmodels`.`employee`.`office_code`,
`classicmodels`.`employee`.`salary` DESC
LIMIT 10
查看生成的WHERE子句!我非常确信你不想手动编写这个子句。以下例子如何?
List<Orderdetail> result = ctx.selectFrom(ORDERDETAIL)
.orderBy(ORDERDETAIL.ORDER_ID, ORDERDETAIL.PRODUCT_ID.desc(),
ORDERDETAIL.QUANTITY_ORDERED.desc())
.seek(orderId, productId, quantityOrdered)
.limit(size)
.fetchInto(Orderdetail.class);
以下代码是生成的 SQL 示例(其中 orderId = 10100,productId = 23,quantityOrdered = 30,size = 10):
SELECT `classicmodels`.`orderdetail`.`orderdetail_id`,
...
FROM `classicmodels`.`orderdetail`
WHERE (`classicmodels`.`orderdetail`.`order_id` > 10100
OR (`classicmodels`.`orderdetail`.`order_id` = 10100
AND `classicmodels`.`orderdetail`.`product_id` < 23)
OR (`classicmodels`.`orderdetail`.`order_id` = 10100
AND `classicmodels`.`orderdetail`.`product_id` = 23
AND `classicmodels`.`orderdetail`.`quantity_ordered` < 30))
ORDER BY `classicmodels`.`orderdetail`.`order_id`,
`classicmodels`.`orderdetail`.`product_id` DESC,
`classicmodels`.`orderdetail`.`quantity_ordered` DESC
LIMIT 10
在这个示例之后,我认为很明显,你应该选择 SEEK 子句,让 jOOQ 做它的工作!看,你甚至可以这样做:
List<Product> result = ctx.selectFrom(PRODUCT)
.orderBy(PRODUCT.BUY_PRICE, PRODUCT.PRODUCT_ID)
.seek(PRODUCT.MSRP.minus(PRODUCT.MSRP.mul(0.35)),
val(productId))
.limit(size)
.fetchInto(Product.class);
你可以在 SeekClausePagination 中练习这些示例,包括使用 jOOQ 内嵌键作为 SEEK 子句的参数。
实现无限滚动
无限滚动是键集分页的经典用法,并且近年来越来越受欢迎。例如,假设我们计划获取如图所示的内容:

图 12.2 – 无限滚动
因此,我们想要对 ORDERDETAIL 表进行无限滚动。在每次滚动时,我们通过 SEEK 子句获取下一个 n 条记录:
public List<Orderdetail> fetchOrderdetailPageAsc(
long orderdetailId, int size) {
List<Orderdetail> result = ctx.selectFrom(ORDERDETAIL)
.orderBy(ORDERDETAIL.ORDERDETAIL_ID)
.seek(orderdetailId)
.limit(size)
.fetchInto(Orderdetail.class);
return result;
}
这种方法获取最后访问的 ORDERDETAIL_ID 和要获取的记录数(size),然后返回一个 jooq.generated.tables.pojos.Orderdetail 的列表,这些列表将通过定义在 @GetMapping("/orderdetail/{orderdetailId}/{size}") 的 Spring Boot REST 控制器端点以 JSON 格式序列化。
在客户端,我们依赖于 JavaScript Fetch API(当然,你也可以使用 XMLHttpRequest、jQuery、AngularJS、Vue、React 等等)来执行 HTTP GET 请求,如下所示:
const postResponse
= await fetch('/orderdetail/${start}/${size}');
const data = await postResponse.json();
为了获取正好三条记录,我们将 ${size} 替换为 3。此外,${start} 占位符应该被替换为最后访问的 ORDERDETAIL_ID,因此 start 变量可以计算如下:
start = data[size-1].orderdetailId;
在滚动时,你的浏览器将在每三条记录后执行一个 HTTP 请求,如下所示:
http://localhost:8080/orderdetail/0/3
http://localhost:8080/orderdetail/3/3
http://localhost:8080/orderdetail/6/3
…
你可以在 SeekInfiniteScroll 中查看这个示例。接下来,让我们看看分页 JOIN 语句的方法。
通过 DENSE_RANK() 分页 JOIN
假设我们想要分页显示办公室(OFFICE)和员工(EMPLOYEE)。如果我们将经典的偏移量或键集分页应用于 OFFICE 和 EMPLOYEE 之间的 JOIN,那么结果可能会被截断。因此,一个办公室可能只能获取其部分员工的记录。例如,当我们认为一个大小为 3 的结果页面包含三个具有所有员工的办公室时,我们实际上只得到一个有三个员工的办公室(即使这个办公室有更多员工)。以下图显示了我们的预期结果与从大小为 3 的页面(办公室)得到的结果:

图 12.3 – 连接分页
为了获得如图左侧所示的结果集,我们可以依赖 DENSE_RANK() 窗口函数,该函数为每个组 b 中 a 的不同值分配一个顺序号,如下查询所示:
Map<Office, List<Employee>> result = ctx.select().from(
select(OFFICE.OFFICE_CODE, OFFICE...,
EMPLOYEE.FIRST_NAME, EMPLOYEE...,
denseRank().over().orderBy(
OFFICE.OFFICE_CODE, OFFICE.CITY).as("rank"))
.from(OFFICE)
.join(EMPLOYEE)
.on(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE)).asTable("t"))
.where(field(name("t", "rank")).between(start, end))
.fetchGroups(Office.class, Employee.class);
start和end变量代表通过DENSE_RANK()设置的办公室范围。以下图应能阐明这一点,其中start = 1和end = 3(下一个包含三个办公室的页面在start = 4和end = 6之间):
![图 12.4 – DENSE_RANK()的效果
![img/B16833_Figure_12.4.jpg]
图 12.4 – DENSE_RANK()的效果
这里是上一个查询的一个更紧凑版本,使用了QUALIFY子句:
Map<Office, List<Employee>> result =
ctx.select(OFFICE.OFFICE_CODE, OFFICE...,
EMPLOYEE.FIRST_NAME, EMPLOYEE...)
.from(OFFICE)
.join(EMPLOYEE)
.on(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE))
.qualify(denseRank().over()
.orderBy(OFFICE.OFFICE_CODE, OFFICE.CITY)
.between(start, end))
.fetchGroups(Office.class, Employee.class);
您可以通过 MySQL 的DenseRankPagination中的三个 REST 控制器端点查看这些示例(偏移量、键集和DENSE_RANK()查询)。在这些所有情况下,返回的Map<Office, List<Employee>>被序列化为 JSON。
通过 ROW_NUMBER()分页数据库视图
让我们考虑以下图,它表示名为PRODUCT_MASTER的数据库视图的快照(它也可以是一个常规表):
![图 12.5 – PRODUCT_MASTER 数据库视图
![img/B16833_Figure_12.5.jpg]
图 12.5 – PRODUCT_MASTER 数据库视图
接下来,我们希望通过PRODUCT_LINE(第一列)来分页这个视图,因此我们必须考虑到PRODUCT_LINE包含重复项的事实。虽然这对于偏移分页不是问题,但它可能会为仅依赖于PRODUCT_LINE和LIMIT(或其对应项)子句的关键集分页产生奇怪的结果。我们可以通过在ORDER BY和WHERE谓词中使用(PRODUCT_LINE, PRODUCT_NAME)组合来消除这个问题。这将按预期工作,因为PRODUCT_NAME包含唯一值。
然而,让我们尝试另一种方法,依赖于ROW_NUMBER()窗口函数。此函数为行分配一个数据库临时值序列。更确切地说,ROW_NUMBER()窗口函数生成一个从值 1 开始,增量 1 的值序列。这是一个在查询执行时动态计算的临时值序列(非持久)。
基于ROW_NUMBER(),我们可以可视化PRODUCT_MASTER,如图所示:
![图 12.6 – ROW_NUMBER()的效果
![img/B16833_Figure_12.6.jpg]
图 12.6 – ROW_NUMBER()的效果
在此上下文中,可以这样表达分页:
var result = ctx.select().from(
select(PRODUCT_MASTER.PRODUCT_LINE,
PRODUCT_MASTER.PRODUCT_NAME, PRODUCT_MASTER.PRODUCT_SCALE,
rowNumber().over().orderBy(
PRODUCT_MASTER.PRODUCT_LINE).as("rowNum"))
.from(PRODUCT_MASTER).asTable("t"))
.where(field(name("t", "rowNum")).between(start, end))
.fetchInto(ProductMaster.class);
或者,我们可以通过QUALIFY子句使其更加紧凑:
var result = ctx.select(PRODUCT_MASTER.PRODUCT_LINE,
PRODUCT_MASTER.PRODUCT_NAME, PRODUCT_MASTER.PRODUCT_SCALE)
.from(PRODUCT_MASTER)
.qualify(rowNumber().over()
.orderBy(PRODUCT_MASTER.PRODUCT_LINE).between(start, end))
.fetchInto(ProductMaster.class);
通过start = 1和end = 5获取大小为 5 的第一页。通过start = 6和end = 10获取下一页的大小为 5。完整的示例在 MySQL 的 bundler 代码中的RowNumberPagination部分可用。
好的,关于分页就说到这里。接下来,让我们解决动态查询(过滤器)的问题。
编写动态查询
通常,一个动态查询包含没有或一些固定部分,以及一些可以在运行时附加的部分,以形成一个对应于特定场景或用例的查询。
重要提示
在 jOOQ 中,即使它们看起来像静态查询(由于 jOOQ 的 API 设计),每个 SQL 都是动态的;因此,它可以分解成可以流畅地重新组合在任何有效 jOOQ 查询中的查询部分。我们已经在 第三章,jOOQ 核心概念,理解 jOOQ 流畅 API 部分中涵盖了这一方面。
动态创建 SQL 语句是 jOOQ 最受欢迎的主题之一,因此让我们尝试涵盖一些在实际应用中可能有用的方法。
使用三元运算符
Java 的三元运算符 (?) 是在运行时塑造查询的最简单方法之一。查看以下示例:
public List<ProductRecord> fetchCarsOrNoCars(
float buyPrice, boolean cars) {
return ctx.selectFrom(PRODUCT)
.where((buyPrice > 0f ? PRODUCT.BUY_PRICE.gt(
BigDecimal.valueOf(buyPrice)) : noCondition())
.and(cars ? PRODUCT.PRODUCT_LINE.in("Classic Cars",
"Motorcycles", "Trucks and Buses", "Vintage Cars") :
PRODUCT.PRODUCT_LINE.in("Plains","Ships", "Trains")))
.fetch();
}
PRODUCT.BUY_PRICE.gt(BigDecimal.valueOf(buyPrice)) 条件仅在传递的 buyPrice 大于 0 时附加;否则,我们依赖便捷的 noCondition() 方法。接下来,根据 cars 标志,我们调整 PRODUCT.PRODUCT_LINE.in() 的值域。通过这个单一的 jOOQ 查询,我们可以在运行时形成四个不同的 SQL 查询,具体取决于 buyPrice 和 cars 的值。
使用 jOOQ 比较器
jOOQ 的 Comparator API 在条件中切换比较运算符时非常方便,同时保持流畅。例如,假设客户端(用户、服务等等)可以在两个员工类别之间进行选择——一个是所有销售代表的类别,另一个是非销售代表的类别。如果客户端选择第一个类别,那么我们希望获取所有 salary 小于 65,000 的员工 (EMPLOYEE)。然而,如果客户端选择第二个类别,那么我们希望获取所有 salary 大于或等于 65,000 的员工 (EMPLOYEE)。而不是编写两个查询或使用其他任何方法,我们可以依赖 jOOQ 的 Comparator.IN 和 Comparator.NOT_IN(注意,在 NOT_IN 的情况下,投影的列应该是 NOT NULL),如下所示:
List<EmployeeRecord> fetchEmployees(boolean isSaleRep) {
return ctx.selectFrom(EMPLOYEE)
.where(EMPLOYEE.SALARY.compare(isSaleRep
? Comparator.IN : Comparator.NOT_IN,
select(EMPLOYEE.SALARY).from(EMPLOYEE)
.where(EMPLOYEE.SALARY.lt(65000))))
.orderBy(EMPLOYEE.SALARY)
.fetch();
}
jOOQ 提供了一个全面的内置比较器列表,包括 EQUALS、GREATER、LIKE 和 IS_DISTINCT_FROM。虽然你可以在 jOOQ 文档中找到所有这些内容,但这里有一个使用 Comparator.LESS 和 Comparator.GREATER 来在 jOOQ 中表达一个可以翻译成四个 SQL 查询的查询的例子,具体取决于 buyPrice 和 msrp 的值:
public List<ProductRecord> fetchProducts(
float buyPrice, float msrp) {
return ctx.selectFrom(PRODUCT)
.where(PRODUCT.BUY_PRICE.compare(
buyPrice < 55f ? Comparator.LESS : Comparator.GREATER,
select(avg(PRODUCT.MSRP.minus(
PRODUCT.MSRP.mul(buyPrice / 100f))))
.from(PRODUCT).where(PRODUCT.MSRP.coerce(Float.class)
.compare(msrp > 100f ?
Comparator.LESS : Comparator.GREATER, msrp))))
.fetch();
}
你可以在 DynamicQuery 中查看这些示例以及其他示例。
使用 SelectQuery、InsertQuery、UpdateQuery 和 DeleteQuery
SelectQuery(InsertQuery、UpdateQuery 和 DeleteQuery)类型的目标是允许以命令式风格表达动态查询。然而,建议避免这种命令式风格,并使用更函数式的方法,正如你将在本章中很快看到的。所以,当你阅读这一节时,请将这句话视为免责声明。
当前面的方法无法使用或查询变得杂乱时,是时候将注意力转向SelectQuery(InsertQuery、UpdateQuery和DeleteQuery)API 了。这些 jOOQ API 对于表达动态查询非常有用,因为它们包含用于轻松添加查询不同部分的方法(例如,条件、连接、HAVING 和 ORDER BY)。
使用 SelectQuery
例如,假设我们的应用程序公开了一个过滤器,可以可选地选择一个价格范围(startBuyPrice和endBuyPrice),产品供应商(productVendor)和产品规模(productScale)来订购PRODUCT。基于客户端的选择,我们应该执行适当的SELECT查询,所以我们首先编写一个SelectQuery,如下所示:
SelectQuery select = ctx.selectFrom(PRODUCT)
.where(PRODUCT.QUANTITY_IN_STOCK.gt(0))
.getQuery();
到目前为止,这个查询没有涉及任何客户端选择。此外,我们针对每个客户端选择,并依靠addConditions()来相应地丰富它们:
if (startBuyPrice != null && endBuyPrice != null) {
select.addConditions(PRODUCT.BUY_PRICE
.betweenSymmetric(startBuyPrice, endBuyPrice));
}
if (productVendor != null) {
select.addConditions(PRODUCT.PRODUCT_VENDOR
.eq(productVendor));
}
if (productScale != null) {
select.addConditions(PRODUCT.PRODUCT_SCALE
.eq(productScale));
}
最后,我们执行查询并获取结果:
select.fetch();
完成!同样的事情也可以这样表达:
Condition condition = PRODUCT.QUANTITY_IN_STOCK.gt(0);
if (startBuyPrice != null && endBuyPrice != null) {
condition = condition.and(PRODUCT.BUY_PRICE
.betweenSymmetric(startBuyPrice, endBuyPrice));
}
if (productVendor != null) {
condition = condition.and(PRODUCT.PRODUCT_VENDOR
.eq(productVendor));
}
if (productScale != null) {
condition = condition.and(PRODUCT.PRODUCT_SCALE
.eq(productScale));
}
SelectQuery select = ctx.selectFrom(PRODUCT)
.where(condition)
.getQuery();
select.fetch();
如果你没有起始条件(一个固定条件),那么你可以从一个虚拟的true条件开始:
Condition condition = trueCondition();
除了trueCondition()之外,我们还可以使用falseCondition()或noCondition()。更多详情请参阅www.jooq.org/doc/latest/manual/sql-building/conditional-expressions/true-false-no-condition/。
接下来,使用and()、or()、andNot()、andExists()等来按适当的方式链式连接可选条件。
然而,条件并不是动态查询中唯一的灵活部分。例如,假设我们有一个查询返回办公室的城市和国家(OFFICE.CITY和OFFICE.COUNTRY)。然而,根据客户端的选择,这个查询还应返回这些办公室的员工(EMPLOYEE)以及这些员工的销售额(SALE)。这意味着我们的查询应该动态地添加连接。通过SelectQuery API,这可以通过addJoin()方法完成,如下所示:
public List<Record> appendTwoJoins(
boolean andEmp, boolean addSale) {
SelectQuery select = ctx.select(OFFICE.CITY,
OFFICE.COUNTRY).from(OFFICE).limit(10).getQuery();
if (andEmp) {
select.addSelect(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME);
select.addJoin(EMPLOYEE,
OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE));
if (addSale) {
select.addSelect(SALE.FISCAL_YEAR,
SALE.SALE_, SALE.EMPLOYEE_NUMBER);
select.addJoin(SALE, JoinType.LEFT_OUTER_JOIN,
EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER));
}
}
return select.fetch();
}
如您所见,addJoin()有多种形式。主要的是有一组addJoin()会隐式生成一个INNER JOIN(如addJoin(TableLike<?> tl, Condition cndtn)),有一组addJoin()允许我们通过JoinType枚举指定连接类型(如addJoin(TableLike<?> tl, JoinType jt, Condition... cndtns)),有一组addJoinOnKey()会根据给定的外键生成ON谓词(如addJoinOnKey(TableLike<?> tl, JoinType jt, ForeignKey<?,?> fk)),以及一组addJoinUsing()依赖于USING子句(如addJoinUsing(TableLike<?> table, Collection<? extends Field<?>> fields))。
在这里使用/提到的addFoo()方法旁边,我们有addFrom()、addHaving()、addGroupBy()、addLimit()、addWindow()等等。你可以在 jOOQ 文档中找到所有这些及其变体。
有时,我们需要多次简单地重用查询的一部分。例如,以下图是通过UNION(几乎)相同的查询获得的:

图 12.7 – 应用 UNION 对客户的付款进行计数和分类
此图背后的查询通过UNION对基于给定类别的客户付款进行计数和分类,如下所示:
SELECT `classicmodels`.`customer`.`customer_number`,
count(*) AS `clazz_[0, 1]`, 0 AS `clazz_[2, 2]`,
0 AS `clazz_[3, 5]`, 0 AS `clazz_[6, 15]`
FROM `classicmodels`.`customer`
JOIN `classicmodels`.`payment` ON
`classicmodels`.`customer`.`customer_number`
= `classicmodels`.`payment`.`customer_number`
GROUP BY `classicmodels`.`customer`.`customer_number`
HAVING count(*) BETWEEN 0 AND 1
UNION
...
HAVING count(*) BETWEEN 2 AND 2
UNION
...
HAVING count(*) BETWEEN 3 AND 5
UNION
SELECT `classicmodels`.`customer`.`customer_number`,
0, 0, 0, count(*)
FROM `classicmodels`.`customer`
JOIN `classicmodels`.`payment` ON
`classicmodels`.`customer`.`customer_number`
= `classicmodels`.`payment`.`customer_number`
GROUP BY `classicmodels`.`customer`.`customer_number`
HAVING count(*) BETWEEN 6 AND 15
然而,如果类的数量变化(这是一个由客户提供的输入参数),那么UNION语句的数量也会变化,并且必须动态地附加HAVING子句。首先,我们可以隔离查询的固定部分,如下所示:
private SelectQuery getQuery() {
return ctx.select(CUSTOMER.CUSTOMER_NUMBER)
.from(CUSTOMER)
.join(PAYMENT)
.on(CUSTOMER.CUSTOMER_NUMBER.eq(PAYMENT.CUSTOMER_NUMBER))
.groupBy(CUSTOMER.CUSTOMER_NUMBER)
.getQuery();
}
接下来,我们应该对每个给定的类UNION一个getQuery()并生成特定的HAVING子句,但在阅读以下重要提示之前不要这样做。
重要提示
注意,在集合操作(如s.union(s))的两边不能使用相同的SelectQuery实例,因此你需要为每个UNION创建一个新的SelectQuery。这似乎是一个可修复的错误,所以当你阅读这本书时,这个注意事项可能不再相关。
这可以通过以下简单代码完成:
public record Clazz(int left, int right) {}
public List<CustomerRecord> classifyCustomerPayments(
Clazz... clazzes) {
SelectQuery[] sq = new SelectQuery[clazzes.length];
for (int i = 0; i < sq.length; i++) {
sq[i] = getQuery(); // create a query for each UNION
}
sq[0].addSelect(count().as("clazz_[" + clazzes[0].left()
+ ", " + clazzes[0].right() + "]"));
sq[0].addHaving(count().between(clazzes[0].left(),
clazzes[0].right()));
for (int i = 1; i < sq.length; i++) {
sq[0].addSelect(val(0).as("clazz_[" + clazzes[i]
.left() + ", " + clazzes[i].right() + "]"));
}
for (int i = 1; i < sq.length; i++) {
for (int j = 0; j < i; j++) {
sq[i].addSelect(val(0));
}
sq[i].addSelect(count());
for (int j = i + 1; j < sq.length; j++) {
sq[i].addSelect(val(0));
}
sq[i].addHaving(count().between(clazzes[i].left(),
clazzes[i].right()));
sq[0].union(sq[i]);
}
return sq[0].fetch();
}
当然,你可以尝试写得更加巧妙和紧凑。这里展示了此方法的调用:
List<CustomerRecord> result = classicModelsRepository
.classifyCustomerPayments(new Clazz(0, 1), new Clazz(2, 2),
new Clazz(3, 5), new Clazz(6, 15));
为了查看这些示例,请参考DynamicQuery应用程序。
InsertQuery, UpdateQuery, 和 DeleteQuery
jOOQ 也支持表示 DML 操作的动态查询。InsertQuery、UpdateQuery和DeleteQuery与SelectQuery的工作原理相同,并公开了一个综合的 API,旨在将 SQL 部分链接成一个有效且动态的 SQL 查询。
让我们看看使用InsertQuery插入一个基于客户提供的数据的PRODUCT(一辆经典汽车)并返回生成的 ID 的例子:
public long insertClassicCar(
String productName, String productVendor,
String productScale, boolean price) {
InsertQuery iq = ctx.insertQuery(PRODUCT);
iq.addValue(PRODUCT.PRODUCT_LINE, "Classic Cars");
iq.addValue(PRODUCT.CODE, 599302);
if (productName != null) {
iq.addValue(PRODUCT.PRODUCT_NAME, productName);
}
if (productVendor != null) {
iq.addValue(PRODUCT.PRODUCT_VENDOR, productVendor);
}
if (productScale != null) {
iq.addValue(PRODUCT.PRODUCT_SCALE, productScale);
}
if (price) {
iq.addValue(PRODUCT.BUY_PRICE,
select(avg(PRODUCT.BUY_PRICE)).from(PRODUCT));
}
iq.setReturning(PRODUCT.getIdentity());
iq.execute();
return iq.getReturnedRecord()
.getValue(PRODUCT.getIdentity().getField(), Long.class);
}
正如你将在 jOOQ 文档中看到的,InsertQuery API 支持许多其他方法,例如addConditions()、onDuplicateKeyIgnore()、onConflict()、setSelect()和addValueForUpdate()。
动态更新或删除怎么样?这里有一个动态更新的非常直观的例子:
public int updateProduct(float oldPrice, float value) {
UpdateQuery uq = ctx.updateQuery(PRODUCT);
uq.addValue(PRODUCT.BUY_PRICE,
PRODUCT.BUY_PRICE.plus(PRODUCT.BUY_PRICE.mul(value)));
uq.addConditions(PRODUCT.BUY_PRICE
.lt(BigDecimal.valueOf(oldPrice)));
return uq.execute();
}
下面是动态删除销售(SALE)的代码示例:
public int deleteSale(int fiscalYear, double sale) {
DeleteQuery dq = ctx.deleteQuery(SALE);
Condition condition = SALE.FISCAL_YEAR
.compare(fiscalYear <= 2003
? Comparator.GREATER : Comparator.LESS, fiscalYear);
if (sale > 5000d) {
condition = condition.or(SALE.SALE_.gt(sale));
}
dq.addConditions(condition);
return dq.execute();
}
你可以在DynamicQuery中查看这些示例。在探索这些 API 之后,花些时间挑战自己编写你自己的动态查询。这真的很有趣,并帮助你熟悉这个简单但强大的 API。
编写通用的动态查询
总有一天,你会意识到你需要的是一个通用的动态查询。例如,你可能遇到一个听起来像这样的场景。你需要根据任意条件从任意表中选取任意列。在这种情况下,仅仅为了改变表名、列名和条件而重复代码将是不高效的。所以,很可能会选择一个通用的动态查询,如下所示:
public <R extends Record> List<R> select(
Table<R> table, Collection<SelectField<?>> fields,
Condition... conditions) {
SelectQuery sq = ctx.selectQuery(table);
sq.addSelect(fields);
sq.addConditions(conditions);
return sq.fetch();
}
调用这个方法可以像以下示例那样进行:
List<ProductRecord> rs1 =
classicModelsRepository.select(PRODUCT,
List.of(PRODUCT.PRODUCT_LINE, PRODUCT.PRODUCT_NAME,
PRODUCT.BUY_PRICE, PRODUCT.MSRP),
PRODUCT.BUY_PRICE.gt(BigDecimal.valueOf(50)),
PRODUCT.MSRP.gt(BigDecimal.valueOf(80)));
List<Record> rs2 =
classicModelsRepository.select(table("product"),
List.of(field("product_line"), field("product_name"),
field("buy_price"), field("msrp")),
field("buy_price").gt(50), field("msrp").gt(80));
如果你只想依赖第一种类型的调用——即基于 jOOQ 生成的代码的调用——那么你可以通过将SelectField<?>替换为TableField<R, ?>来强制通用方法的类型安全,如下所示:
public <R extends Record> List<R> select(
Table<R> table, Collection<TableField<R, ?>> fields,
Condition... conditions) {
SelectQuery sq = ctx.selectQuery(table);
sq.addSelect(fields);
sq.addConditions(conditions);
return sq.fetch();
}
这次,只有第一次调用(List<ProductRecord> rs1 = …)编译并工作。同样的情况也适用于 DML 操作——例如,在任意表中插入:
public <R extends Record> int insert (
Table<R> table, Map<TableField<R, ?>, ?> values) {
InsertQuery iq = ctx.insertQuery(table);
iq.addValues(values);
return iq.execute();
}
这里是前一种方法的调用示例:
int ri = classicModelsRepository.insert(PRODUCT,
Map.of(PRODUCT.PRODUCT_LINE, "Classic Cars",
PRODUCT.CODE, 599302,
PRODUCT.PRODUCT_NAME, "1972 Porsche 914"));
或者,对于任意的更新,我们可以编写以下方法:
public <R extends Record> int update(Table<R> table,
Map<TableField<R, ?>, ?> values, Condition... conditions) {
UpdateQuery uq = ctx.updateQuery(table);
uq.addValues(values);
uq.addConditions(conditions);
return uq.execute();
}
这里是前一种方法的调用示例:
int ru = classicModelsRepository.update(SALE,
Map.of(SALE.TREND, "UP", SALE.HOT, true),
SALE.TREND.eq("CONSTANT"));
或者,对于任意的删除,我们可以编写以下方法:
public <R extends Record> int delete(Table<R> table,
Condition... conditions) {
DeleteQuery dq = ctx.deleteQuery(table);
dq.addConditions(conditions);
return dq.execute();
}
这里是前一种方法的调用示例:
int rd1 = classicModelsRepository.delete(SALE,
SALE.TREND.eq("UP"));
int rd2 = classicModelsRepository.delete(table("sale"),
field("trend").eq("CONSTANT"));
你可以在GenericDynamicQuery旁边看到这些示例。
写作功能动态查询
功能动态查询将这个主题提升到了新的水平。然而,让我们尝试看看我们应该如何以及为什么应该从零到功能实现地演变查询。让我们假设我们为某个组织的销售部门开发应用程序,我们必须编写一个查询来过滤销售(SALE)按财政年度(SALE.FISCAL_YEAR)。最初(第一天),我们可以像下面这样操作:
// Day 1
public List<SaleRecord>
filterSaleByFiscalYear(int fiscalYear) {
return ctx.selectFrom(SALE)
.where(SALE.FISCAL_YEAR.eq(fiscalYear))
.fetch();
}
当每个人都对结果感到满意时,我们收到了一个新的请求,需要获取某种趋势(SALE.TREND)的销售。我们在第一天已经做过这个操作,所以第二天重复它没有问题:
// Day 2
public List<SaleRecord> filterSaleByTrend(String trend) {
return ctx.selectFrom(SALE)
.where(SALE.TREND.eq(trend))
.fetch();
}
这个过滤器与第一天的过滤器相同,只是它有一个不同的条件/过滤器。我们意识到继续这样下去将导致大量类似的方法,这些方法只是为不同的过滤器重复代码,这意味着大量的样板代码。在反思这个方面时,我们刚刚收到了一个紧急请求,需要按财政年度和趋势进行销售过滤。所以,我们第三天的糟糕解决方案如下所示:
// Day 3
public List<SaleRecord> filterSaleByFiscalYearAndTrend(
int fiscalYear, String trend) {
return ctx.selectFrom(SALE)
.where(SALE.FISCAL_YEAR.eq(fiscalYear)
.and(SALE.TREND.eq(trend)))
.fetch();
}
经过 3 天后,我们意识到这是不可接受的。代码变得冗长,难以维护,并且容易出错。
在第四天,当我们寻找解决方案时,在 jOOQ 文档中注意到where()方法还提供了where(Collection<? extends Condition> clctn)和where(Condition... cndtns)。这意味着我们可以简化我们的解决方案,如下所示:
// Day 4
public List<SaleRecord>
filterSaleBy(Collection<Condition> cf) {
return ctx.selectFrom(SALE)
.where(cf)
.fetch();
}
这相当不错,因为我们可以在不修改filterSaleBy()方法的情况下传递任何一组条件。以下是一个调用示例:
List<SaleRecord> result =
classicModelsRepository.filterSaleBy(
List.of(SALE.FISCAL_YEAR.eq(2004), SALE.TREND.eq("DOWN"),
SALE.EMPLOYEE_NUMBER.eq(1370L)));
然而,这并不是类型安全的。例如,这个调用的错误只有在编译时才会被发现(查看粗体代码):
List<SaleRecord> result =
classicModelsRepository.filterSaleBy(
List.of(SALE.FISCAL_YEAR.eq(2004), SALE.TREND.eq("DOWN"),
EMPLOYEE.EMPLOYEE_NUMBER.eq(1370L)));
嗯,新的一天带来了新的想法!在第 5 天,我们定义了一个接口来防止第 4 天的类型安全问题。这是一个函数式接口,如下所示:
// Day 5
@FunctionalInterface
public interface SaleFunction<Sale, Condition> {
Condition apply(Sale s);
}
而 filterSaleBy() 变成了以下形式:
public List<SaleRecord> filterSaleBy(
SaleFunction<Sale, Condition> sf) {
return ctx.selectFrom(SALE)
.where(sf.apply(SALE))
.fetch();
}
问题解决!这次,我们可以运行这个类型安全的调用:
List<SaleRecord> result = classicModelsRepository
.filterSaleBy(s -> s.SALE_.gt(4000d));
在第 6 天,我们的同事马克注意到了这段代码,他向我们指出 Java 8 已经有了这个函数式接口,称为 java.util.function.Function<T, R>。因此,没有必要定义我们的 SaleFunction,因为 Function 可以完成这项工作,如下所示:
// Day 6
public List<SaleRecord> filterSaleBy(
Function<Sale, Condition> f) {
return ctx.selectFrom(SALE)
.where(f.apply(SALE))
.fetch();
}
在第 7 天,我们注意到调用 filterSaleBy() 只适用于单个条件。然而,我们还需要传递多个条件(就像我们在使用 Collection<Condition> 的时候所做的那样)。这导致了修改 filterSaleBy() 以接受一个 Function 数组的决定。挑战在于应用这个 Function 数组,解决方案依赖于 Arrays.stream(array) 或 Stream.of(array),如下所示(使用你认为更具有表达力的一个;作为一个例子,在幕后,Stream.of() 调用了 Arrays.stream()):
// Day 7
public List<SaleRecord> filterSaleBy(
Function<Sale, Condition>... ff) {
return ctx.selectFrom(SALE)
.where(Stream.of(ff).map(f -> f.apply(SALE))
.collect(toList()))
.fetch();
}
现在,我们可以编写类型安全的调用,如下所示:
List<SaleRecord> result = classicModelsRepository
.filterSaleBy(s -> s.SALE_.gt(4000d),
s -> s.TREND.eq("DOWN"),
s -> s.EMPLOYEE_NUMBER.eq(1370L));
太棒了!第 8 天是一个重要的日子,因为我们设法通过编写泛型代码使这段代码适用于任何表和条件:
public <T extends Table<R>, R extends Record> List<R>
filterBy(T t, Function<T, Condition>... ff) {
return ctx.selectFrom(t)
.where(Stream.of(ff).map(f -> f.apply(t)).collect(toList()))
.fetch();
}
这里有一些调用示例:
List<SaleRecord> result1
= classicModelsRepository.filterBy(SALE,
s -> s.SALE_.gt(4000d), s -> s.TREND.eq("DOWN"),
s -> s.EMPLOYEE_NUMBER.eq(1370L));
List<EmployeeRecord> result2 = classicModelsRepository
.filterBy(EMPLOYEE, e -> e.JOB_TITLE.eq("Sales Rep"),
e -> e.SALARY.gt(55000));
List<Record> result3 = classicModelsRepository
.filterBy(table("employee"),
e -> field("job_title", String.class).eq("Sales Rep"),
e -> field("salary", Integer.class).gt(55000));
在第 9 天,我们开始考虑调整这个查询。例如,我们决定不再通过 selectFrom() 获取所有字段,而是添加一个参数来接收应该获取的字段,作为一个 Collection<TableField<R, ?>>。此外,我们决定将这些字段集合的创建推迟到它们真正被使用时,为了实现这一点,我们将集合包装在一个 Supplier 中,如下所示:
public <T extends Table<R>, R extends Record> List<Record>
filterBy(T t, Supplier<Collection<TableField<R, ?>>>
select, Function<T, Condition>... ff) {
return ctx.select(select.get())
.from(t)
.where(Stream.of(ff).map(
f -> f.apply(t)).collect(toList()))
.fetch();
}
这里有一个调用示例:
List<Record> result = classicModelsRepository.filterBy(SALE,
() -> List.of(SALE.SALE_ID, SALE.FISCAL_YEAR),
s -> s.SALE_.gt(4000d), s -> s.TREND.eq("DOWN"),
s -> s.EMPLOYEE_NUMBER.eq(1370L));
可能你还想支持以下这样的调用:
List<Record> result = classicModelsRepository
.filterBy(table("sale"),
() -> List.of(field("sale_id"), field("fiscal_year")),
s -> field("sale").gt(4000d),
s -> field("trend").eq("DOWN"),
s -> field("employee_number").eq(1370L));
在这种情况下,将 TableField<R, ?> 替换为 SelectField<?>,如下所示:
public <T extends Table<R>, R extends Record> List<Record>
filterBy (T t, Supplier<Collection<SelectField<?>>>
select, Function<T, Condition>... ff) {
return ctx.select(select.get())
.from(t)
.where(Stream.of(ff).map(f -> f.apply(t))
.collect(toList()))
.fetch();
}
完成!我希望你发现这个故事和示例对你的功能性泛型动态查询有用且富有启发性。在此之前,你可以在名为 FunctionalDynamicQuery 的捆绑代码中查看这些示例。
无限滚动和动态筛选
在本章的最后部分,让我们将我们的两个主要主题——分页和动态查询——结合起来。在之前的 实现无限滚动 部分中,我们为 ORDERDETAIL 表实现了无限滚动。现在,让我们为 ORDERDETAIL 添加一些筛选器,允许客户端选择价格和订购数量的范围,如图所示:

图 12.8 – 无限滚动和动态筛选
我们可以通过融合 SEEK 和 SelectQuery 的力量轻松实现这种行为:
public List<Orderdetail> fetchOrderdetailPageAsc(
long orderdetailId, int size, BigDecimal priceEach,
Integer quantityOrdered) {
SelectQuery sq = ctx.selectFrom(ORDERDETAIL)
.orderBy(ORDERDETAIL.ORDERDETAIL_ID)
.seek(orderdetailId)
.limit(size)
.getQuery();
if (priceEach != null) {
sq.addConditions(ORDERDETAIL.PRICE_EACH.between(
priceEach.subtract(BigDecimal.valueOf(50)), priceEach));
}
if (quantityOrdered != null) {
sq.addConditions(ORDERDETAIL.QUANTITY_ORDERED.between(
quantityOrdered - 25, quantityOrdered));
}
return sq.fetchInto(Orderdetail.class);
}
以下示例 URL 涉及加载价格在 50 到 100 之间,订购数量在 50 到 75 之间的前三个记录的第一页:
http://localhost:8080/orderdetail/0/3
?priceEach=100&quantityOrdered=75
你可以在名为 SeekInfiniteScrollFilter 的 MySQL 中找到完整的示例。
概述
这是一章相对简短的章节,关于分页和动态查询。正如你所见,jOOQ 在这两个主题上都表现出色,并提供了支持和 API,使我们能够直观且快速地实现从最简单到最复杂的场景。在本章的第一部分,我们介绍了偏移量和键集分页(包括无限滚动、花哨的DENSE_RANK()和ROW_NUMBER()方法)。在第二部分,我们讨论了动态查询,包括三元运算符、Comparator API、SelectQuery、InsertQuery、UpdateQuery和DeleteQuery API,以及它们各自的泛型和功能动态查询。
在下一章中,我们将讨论利用 SQL 函数。
第四部分:jOOQ 和高级 SQL
在本部分,我们将介绍高级 SQL。这是 jOOQ 如鱼得水的地方。
到本部分结束时,你将了解如何使用 jOOQ 利用一些最强大的 SQL 功能,例如存储过程、CTEs、窗口函数和数据库视图。
本部分包含以下章节:
-
第十三章,利用 SQL 函数
-
第十四章,派生表、CTEs 和视图
-
第十五章,调用和创建存储函数和过程
-
第十六章,处理别名和 SQL 模板
-
第十七章,jOOQ 中的多租户
第十三章:利用 SQL 函数
从数学和统计计算到字符串和日期时间的操作,再到不同类型的聚合、排名和分组,SQL 内置函数在许多场景下都非常实用。根据它们的目标和用途,有不同的函数类别,正如你将看到的,jOOQ 对这些支持给予了主要关注。基于这些类别,本章的议程遵循以下要点:
-
常规函数
-
聚合函数
-
窗口函数
-
聚合作为窗口函数
-
聚合函数和
ORDER BY -
有序集聚合函数(
WITHIN GROUP) -
分组、过滤、唯一性和函数
-
分组集
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter13。
常规函数
作为 SQL 用户,你可能已经使用过很多常规或常见的 SQL 函数,例如处理NULL值的函数、数值函数、字符串函数、日期时间函数等等。虽然 jOOQ 手册代表了一个结构化的信息源,列出了所有支持的 SQL 内置函数的命名法,但我们正在尝试完成一系列示例,旨在让你熟悉在不同场景下的 jOOQ 语法。让我们先从处理NULL值的 SQL 函数开始讨论。
如果你需要快速了解一些简单和常见的NULL相关内容,那么可以快速查看捆绑代码中可用的someNullsStuffGoodToKnow()方法。
处理NULL值的 SQL 函数
SQL 为我们查询中处理NULL值提供了几个函数。接下来,让我们介绍COALESCE()、DECODE()、IIF()、NULLIF()、NVL()和NVL2()函数。让我们从COALESCE()开始。
COALESCE()
处理NULL值最受欢迎的函数之一是COALESCE()。这个函数从其n个参数列表中返回第一个非空值。
例如,假设我们想要为每个DEPARTMENT计算从CASH、ACCOUNTS_RECEIVABLE或INVENTORIES中扣除 25%,以及从ACCRUED_LIABILITIES、ACCOUNTS_PAYABLE或ST_BORROWING中扣除 25%。由于这个顺序是严格的,如果其中一个是NULL值,我们就选择下一个,依此类推。如果所有都是NULL,那么我们将NULL替换为 0。依靠 jOOQ 的coalesce()方法,我们可以将查询编写如下:
ctx.select(DEPARTMENT.NAME, DEPARTMENT.OFFICE_CODE,
DEPARTMENT.CASH, ...,
round(coalesce(DEPARTMENT.CASH,
DEPARTMENT.ACCOUNTS_RECEIVABLE,
DEPARTMENT.INVENTORIES,inline(0)).mul(0.25),
2).as("income_deduction"),
round(coalesce(DEPARTMENT.ACCRUED_LIABILITIES,
DEPARTMENT.ACCOUNTS_PAYABLE, DEPARTMENT.ST_BORROWING,
inline(0)).mul(0.25), 2).as("expenses_deduction"))
.from(DEPARTMENT).fetch();
注意显式使用inline()来内联整数 0。只要你知道这个整数是一个常数,就没有必要依赖val()来渲染绑定变量(占位符)。使用inline()非常适合 SQL 函数,这些函数通常依赖于常数参数或具有可以轻松内联的常数项的数学公式。如果你需要快速回顾inline()与val()的区别,那么请快速回顾第三章,jOOQ 核心概念。
除了这里使用的coalesce(Field<T> field, Field<?>... fields)之外,jOOQ 还提供了两种其他风味:coalesce(Field<T> field, T value)和coalesce(T value, T... values)。
这里是另一个例子,它依赖于coalesce()方法来填补DEPARTMENT.FORECAST_PROFIT列中的空白。每个FORECAST_PROFIT值为NULL的值由以下查询填充:
ctx.select(DEPARTMENT.NAME, DEPARTMENT.OFFICE_CODE, …
coalesce(DEPARTMENT.FORECAST_PROFIT,
select(
avg(field(name("t", "forecast_profit"), Double.class)))
.from(DEPARTMENT.as("t"))
.where(coalesce(field(name("t", "profit")), 0)
.gt(coalesce(DEPARTMENT.PROFIT, 0))
.and(field(name("t", "forecast_profit")).isNotNull())))
.as("fill_forecast_profit"))
.from(DEPARTMENT)
.orderBy(DEPARTMENT.DEPARTMENT_ID).fetch();
因此,对于每个FORECAST_PROFIT等于NULL的行,我们使用一个自定义插值公式,该公式表示所有非空FORECAST_PROFIT值的平均值,其中利润(PROFIT)大于当前行的利润。
接下来,让我们谈谈DECODE()。
DECODE()
在某些方言(例如,在 Oracle 中),我们有DECODE()函数,它在查询中充当 if-then-else 逻辑。DECODE(x, a, r1, r2)等同于以下代码:
IF x = a THEN
RETURN r1;
ELSE
RETURN r2;
END IF;
或者,由于DECODE使NULL安全比较,它更像是IF x IS NOT DISTINCT FROM a THEN …。
让我们尝试计算一个财务指数,公式为((DEPARTMENT.LOCAL_BUDGET * 0.25) * 2) / 100。由于DEPARTMENT.LOCAL_BUDGET可能为NULL,我们更愿意用 0 来替换这种出现。依靠 jOOQ 的decode()方法,我们有以下代码:
ctx.select(DEPARTMENT.NAME, DEPARTMENT.OFFICE_CODE,
DEPARTMENT.LOCAL_BUDGET, decode(DEPARTMENT.LOCAL_BUDGET,
castNull(Double.class), 0, DEPARTMENT.LOCAL_BUDGET.mul(0.25))
.mul(2).divide(100).as("financial_index"))
.from(DEPARTMENT)
.fetch();
DECODE()部分可以这样理解:
IF DEPARTMENT.LOCAL_BUDGET = NULL THEN
RETURN 0;
ELSE
RETURN DEPARTMENT.LOCAL_BUDGET * 0.25;
END IF;
但不要从这里得出结论,认为DECODE()只接受这种简单的逻辑。实际上,DECODE()的语法更复杂,看起来像这样:
DECODE (x, a1, r1[, a2, r2], ...,[, an, rn] [, d]);
在这种语法中,以下规则适用:
-
x与另一个参数a1,a2, …,an 进行比较。 -
a1,a2, …,an 依次与第一个参数进行比较;如果任何比较x = a1,x = a2, …,x = an 返回true,则DECODE()函数通过返回结果终止。 -
r1,r2, …,rn 是对应于xi= ai,i = (1…n)的结果。 -
d是一个表达式,如果未找到xi=ai,i = (1…n)的匹配项,则应返回该表达式。
由于 jOOQ 使用CASE表达式来模拟DECODE(),因此可以在 jOOQ 支持的所有方言中使用它,所以这里再看一个例子:
ctx.select(DEPARTMENT.NAME, DEPARTMENT.OFFICE_CODE,…,
decode(DEPARTMENT.NAME,
"Advertising", "Publicity and promotion",
"Accounting", "Monetary and business",
"Logistics", "Facilities and supplies",
DEPARTMENT.NAME).concat("department")
.as("description"))
.from(DEPARTMENT)
.fetch();
因此,在这种情况下,如果部门名称是广告,会计或物流,则将其替换为有意义的描述;否则,我们简单地返回当前名称。
此外,DECODE() 可以与 ORDER BY、GROUP BY 或聚合函数一起使用。虽然更多示例可以在捆绑的代码中看到,但这里还有一个使用 DECODE() 与 GROUP BY 来计算 BUY_PRICE 大于/等于/小于 MSRP 一半的示例:
ctx.select(field(name("t", "d")), count())
.from(select(decode(sign(
PRODUCT.BUY_PRICE.minus(PRODUCT.MSRP.divide(2))),
1, "Buy price larger than half of MSRP",
0, "Buy price equal to half of MSRP",
-1, "Buy price smaller than half of MSRP").as("d"))
.from(PRODUCT)
.groupBy(PRODUCT.BUY_PRICE, PRODUCT.MSRP).asTable("t"))
.groupBy(field(name("t", "d")))
.fetch();
这里是使用嵌套 DECODE() 的另一个示例:
ctx.select(DEPARTMENT.NAME, DEPARTMENT.OFFICE_CODE,
DEPARTMENT.LOCAL_BUDGET, DEPARTMENT.PROFIT,
decode(DEPARTMENT.LOCAL_BUDGET,
castNull(Double.class), DEPARTMENT.PROFIT,
decode(sign(DEPARTMENT.PROFIT.minus(
DEPARTMENT.LOCAL_BUDGET)),
1, DEPARTMENT.PROFIT.minus(DEPARTMENT.LOCAL_BUDGET),
0, DEPARTMENT.LOCAL_BUDGET.divide(2).mul(-1),
-1, DEPARTMENT.LOCAL_BUDGET.mul(-1)))
.as("profit_balance"))
.from(DEPARTMENT)
.fetch();
对于给定的 sign(a, b),如果 a > b 则返回 1,如果 a = b 则返回 0,如果 a < b 则返回 -1。因此,根据以下输出,此代码可以很容易地解释:


图 13.1 – 输出
更多示例可以在 函数 捆绑代码中找到。
IIF()
IIF() 函数通过三个参数实现 if-then-else 逻辑,如下所示(这类似于稍后介绍的 NVL2() 函数):
IIF(boolean_expr, value_for_true_case, value_for_false_case)
它评估第一个参数(boolean_expr),并分别返回第二个参数(value_for_true_case)和第三个参数(value_for_false_case)。
例如,以下 jOOQ iif() 函数的使用评估了 DEPARTMENT.LOCAL_BUDGET.isNull() 表达式,并输出文本 没有预算 或 有预算:
ctx.select(DEPARTMENT.DEPARTMENT_ID, DEPARTMENT.NAME,
iif(DEPARTMENT.LOCAL_BUDGET.isNull(),
"NO BUDGET", "HAS BUDGET").as("budget"))
.from(DEPARTMENT).fetch();
包含嵌套 IIF() 使用在内的更多示例可以在捆绑的代码中找到。
NULLIF()
NULLIF(expr1, expr2) 函数如果参数相等则返回 NULL。否则,它返回第一个参数(expr1)。
例如,在传统数据库中,混合使用 NULL 和空字符串作为缺失值是一种常见做法。我们故意在 OFFICE 表的 OFFICE.COUNTRY 上创建了这样的案例。
由于空字符串不是 NULL 值,即使对我们来说 NULL 值和空字符串可能有相同的挖掘,使用 ISNULL() 也不会返回它们。使用 jOOQ 的 nullif() 方法是查找所有缺失数据(NULL 值和空字符串)的便捷方法,如下所示:
ctx.select(OFFICE.OFFICE_CODE, nullif(OFFICE.COUNTRY, ""))
.from(OFFICE).fetch();
ctx.selectFrom(OFFICE)
.where(nullif(OFFICE.COUNTRY, "").isNull()).fetch();
这些示例可以在 函数 捆绑代码中找到。
IFNULL() 和 ISNULL()
IFNULL(expr1, expr2) 和 ISNULL(expr1, expr2) 函数接受两个参数,如果第一个参数不是 NULL 则返回第一个参数。否则,它们返回第二个参数。前者类似于稍后介绍的 Oracle 的 NVL() 函数,而后者是 SQL Server 的特定函数。这两个函数都通过 CASE 表达式由 jOOQ 在所有原生不支持它们的方言中进行了模拟。
例如,以下代码片段通过 jOOQ 的 ifnull() 和 isnull() 方法为 DEPARTMENT.LOCAL_BUDGET 的每个 NULL 值生成 0:
ctx.select(DEPARTMENT.DEPARTMENT_ID, DEPARTMENT.NAME,
ifnull(DEPARTMENT.LOCAL_BUDGET, 0).as("budget_if"),
isnull(DEPARTMENT.LOCAL_BUDGET, 0).as("budget_is"))
.from(DEPARTMENT)
.fetch();
这里是另一个获取客户邮政编码或地址的示例:
ctx.select(
ifnull(CUSTOMERDETAIL.POSTAL_CODE,
CUSTOMERDETAIL.ADDRESS_LINE_FIRST).as("address_if"),
isnull(CUSTOMERDETAIL.POSTAL_CODE,
CUSTOMERDETAIL.ADDRESS_LINE_FIRST).as("address_is"))
.from(CUSTOMERDETAIL).fetch();
更多示例可以在 函数 捆绑代码中找到。
NVL() 和 NVL2()
一些方言(例如,Oracle)支持名为 NVL() 和 NVL2() 的两个函数。jOOQ 为所有原生不支持它们的方言进行了模拟。前者类似于稍后介绍的 Oracle 的 NVL() 函数,而后者类似于 IIF()。因此,NVL(expr1, expr2) 如果第一个参数不是 NULL 则产生第一个参数;否则,它产生第二个参数。
例如,让我们使用 jOOQ 的nvl()方法来应用在金融中使用的方差公式,以计算DEPARTMENT.FORECAST_PROFIT和DEPARTMENT.PROFIT之间的预测与实际结果差异,如下所示:
ctx.select(DEPARTMENT.NAME, ...,
round((nvl(DEPARTMENT.PROFIT, 0d).divide(
nvl(DEPARTMENT.FORECAST_PROFIT, 10000d)))
.minus(1d).mul(100), 2).concat("%").as("nvl"))
.from(DEPARTMENT)
.fetch();
如果PROFIT是NULL,则我们将其替换为 0,如果FORECAST_PROFIT是NULL,则我们将其替换为默认利润 10,000。挑战自己通过ISNULL()编写这个查询。
另一方面,NVL2(expr1, expr2, expr3)评估第一个参数(expr1)。如果expr1不是NULL,则返回第二个参数(expr2);否则,返回第三个参数(expr3)。
例如,每个EMPLOYEE都有一个工资和一个可选的COMMISSION(缺失的佣金是NULL)。让我们通过 jOOQ 的nvl2()和iif()来获取工资+佣金,如下所示:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
iif(EMPLOYEE.COMMISSION.isNull(),EMPLOYEE.SALARY,
EMPLOYEE.SALARY.plus(EMPLOYEE.COMMISSION))
.as("iif1"),
iif(EMPLOYEE.COMMISSION.isNotNull(),
EMPLOYEE.SALARY.plus(EMPLOYEE.COMMISSION), EMPLOYEE.SALARY)
.as("iif2"),
nvl2(EMPLOYEE.COMMISSION,
EMPLOYEE.SALARY.plus(EMPLOYEE.COMMISSION), EMPLOYEE.SALARY)
.as("nvl2"))
.from(EMPLOYEE)
.fetch();
所有这三列—iif1、iif2和nvl2—应包含相同的数据。遗憾的是,在某些 Oracle 情况下,NVL可能比COALESCE表现得更好。更多详情,请考虑阅读这篇文章:connor-mcdonald.com/2018/02/13/nvl-vs-coalesce/. 你可以检查本节中函数捆绑代码的所有示例。接下来,让我们谈谈数值函数。
数值函数
jOOQ 支持一系列全面的数值函数,包括ABS()、SIN()、COS()、EXP()、FLOOR()、GREATEST()、LEAST()、LN()、POWER()、SIGN()、SQRT()等等。主要的是,jOOQ 公开了一组方法,这些方法的名称与这些 SQL 函数相匹配,并支持适当的参数数量和类型。
由于你可以在 jOOQ 手册中找到并举例说明所有这些函数,让我们在这里尝试两个结合几个函数以实现共同目标的例子。例如,计算斐波那契数的著名公式是 Binet 公式(注意不需要递归!):
Fib(n) = (1.6180339^n – (–0.6180339)^n) / 2.236067977
在 jOOQ/SQL 中编写这个公式需要我们使用power()数值函数,如下所示(n是要计算的数字):
ctx.fetchValue(round((power(1.6180339, n).minus(
power(-0.6180339, n))).divide(2.236067977), 0));
那么计算表示为(latitude1, longitude1)和(latitude2, longitude2)的两个点之间的距离呢?当然,就像计算斐波那契数的情况一样,这样的计算通常在数据库外部(直接在 Java 中)或 UDF 或存储过程中完成,但尝试在SELECT语句中解决它们是快速练习一些数值函数并熟悉 jOOQ 语法的良好机会。所以,我们开始所需的数学计算:
a = POWER(SIN((latitude2 − latitude1) / 2.0)), 2)
+ COS(latitude1) * COS(latitude2)
* POWER (SIN((longitude2 − longitude1) / 2.0), 2);
result = (6371.0 * (2.0 * ATN2(SQRT(a),SQRT(1.0 − a))));
这次,我们需要 jOOQ 的power()、sin()、cos()、atn2()和sqrt()数值方法,如下所示:
double pi180 = Math.PI / 180;
Field<BigDecimal> a = (power(sin(val((latitude2 - latitude1)
* pi180).divide(2d)), 2d).plus(cos(latitude1 * pi180)
.mul(cos(latitude2 * pi180)).mul(power(sin(val((
longitude2 - longitude1) * pi180).divide(2d)), 2d))));
ctx.fetchValue(inline(6371d).mul(inline(2d)
.mul(atan2(sqrt(a), sqrt(inline(1d).minus(a))))));
你可以在函数捆绑代码中练习这些示例。
字符串函数
正如 SQL 数值函数的情况一样,jOOQ 支持一组令人印象深刻的 SQL 字符串函数,包括ASCII()、CONCAT()、OVERLAY()、LOWER()、UPPER()、LTRIM()、RTRIM()等。你可以在 jOOQ 手册中找到每个函数的示例,所以在这里,让我们尝试使用几个字符串函数来获取输出,如图所示:
![图 13.2 – 应用多个 SQL 字符串函数]
![图 13.2]
![图 13.2 – 应用多个 SQL 字符串函数]
将我们拥有的东西转换为想要的东西可以通过 jOOQ 的几种方法表达,包括concat()、upper()、space()、substring()、lower()和rpad()——当然,你可以以不同的方式优化或编写以下查询:
ctx.select(concat(upper(EMPLOYEE.FIRST_NAME), space(1),
substring(EMPLOYEE.LAST_NAME, 1, 1).concat(". ("),
lower(EMPLOYEE.JOB_TITLE),
rpad(val(")"), 4, '.')).as("employee"))
.from(EMPLOYEE)
.fetch();
你可以在函数捆绑代码旁边查看几个通过分隔符拆分字符串的示例。
日期时间函数
本节最后讨论的函数类别包括日期时间函数。主要的是,jOOQ 公开了一系列日期时间函数,可以大致分为操作java.sql.Date、java.sql.Time和java.sql.Timestamp的函数,以及操作 Java 8 日期时间的函数,java.time.LocalDate、java.time.LocalDateTime和java.time.OffsetTime。尽管如此,jOOQ 不能使用java.time.Duration或Period类,因为它们与标准 SQL 间隔的工作方式不同。
此外,jOOQ 提供了一个替代 JDBC 缺少的java.sql.Interval数据类型,名为org.jooq.types.Interval,有三个实现:DayToSecond、YearToMonth和YearToSecond。
这里有一些相当简单直观的示例。第一个示例通过java.sql.Date和java.time.LocalDate获取当前日期:
Date r = ctx.fetchValue(currentDate());
LocalDate r = ctx.fetchValue(currentLocalDate());
下一个示例将 ISO 8601 DATE字符串字面值转换为java.sql.Date数据类型:
Date r = ctx.fetchValue(date("2024-01-29"));
向Date和LocalDate添加 10 天的间隔可以这样操作:
var r = ctx.fetchValue(
dateAdd(Date.valueOf("2022-02-03"), 10).as("after_10_days"));
var r = ctx.fetchValue(localDateAdd(
LocalDate.parse("2022-02-03"), 10).as("after_10_days"));
或者,添加 3 个月的间隔可以这样操作:
var r = ctx.fetchValue(dateAdd(Date.valueOf("2022-02-03"),
new YearToMonth(0, 3)).as("after_3_month"));
通过 SQL 的EXTRACT()和 jOOQ 的dayOfWeek()函数提取星期几(1 = 星期日,2 = 星期一,...,7 = 星期六)可以这样操作:
int r = ctx.fetchValue(dayOfWeek(Date.valueOf("2021-05-06")));
int r = ctx.fetchValue(extract(
Date.valueOf("2021-05-06"), DatePart.DAY_OF_WEEK));
你可以在函数捆绑代码中查看更多示例。在下一节中,我们将讨论聚合函数。
聚合函数
最常见的聚合函数(按任意顺序)是AVG()、COUNT()、MAX()、MIN()和SUM(),包括它们的DISTINCT变体。我非常确信你对这些聚合函数非常熟悉,并且你在许多查询中已经使用过它们。例如,这里有两个计算按财政年度分组的销售流行调和几何平均值的SELECT语句。在这里,我们使用了 jOOQ 的sum()和avg()函数:
// Harmonic mean: n / SUM(1/xi), i=1…n
ctx.select(SALE.FISCAL_YEAR, count().divide(
sum(inline(1d).divide(SALE.SALE_))).as("harmonic_mean"))
.from(SALE).groupBy(SALE.FISCAL_YEAR).fetch();
而在这里,我们计算几何平均值:
// Geometric mean: EXP(AVG(LN(n)))
ctx.select(SALE.FISCAL_YEAR, exp(avg(ln(SALE.SALE_)))
.as("geometric_mean"))
.from(SALE).groupBy(SALE.FISCAL_YEAR).fetch();
但正如你所知(或者你很快就会了解到),还有许多其他汇总具有相同的目标,即对一组行执行某些计算并返回单个输出行。同样,jOOQ 公开了具有与汇总名称相同名称或表示建议快捷方式的方法。
接下来,让我们看看一些不太流行但在统计学、金融、科学和其他领域常用的汇总函数。其中之一是用于计算标准差的,(en.wikipedia.org/wiki/Standard_deviation)。在 jOOQ 中,我们有stddevSamp()用于样本和stddevPop()用于总体。以下是一个计算 SSD、PSD 以及通过总体方差(接下来将介绍)对按财政年度分组的销售进行 PSD 仿真的示例:
ctx.select(SALE.FISCAL_YEAR,
stddevSamp(SALE.SALE_).as("samp"), // SSD
stddevPop(SALE.SALE_).as("pop1"), // PSD
sqrt(varPop(SALE.SALE_)).as("pop2")) // PSD emulation
.from(SALE).groupBy(SALE.FISCAL_YEAR).fetch();
SSD 和 PSD 在 MySQL、PostgreSQL、SQL Server、Oracle 以及许多其他方言中都得到支持,并在各种问题中非常有用,从金融、统计学、预测等等。例如,在统计学中,我们有标准分数(或所谓的 z 分数),它表示某个观察值相对于总体平均值的 SD 数,其公式为 z = (x - µ) / σ(z 是 z 分数,x 是观察值,µ是平均值,σ是 SD)。您可以在这里了解更多信息:en.wikipedia.org/wiki/Standard_score。
现在,考虑到我们在DAILY_ACTIVITY中存储了销售数量(DAILY_ACTIVITY.SALES)和访客数量(DAILY_ACTIVITY.VISITORS),并且我们想要获取有关这些数据的一些信息,由于销售和访客之间没有直接比较,我们必须提出一些有意义的表示,而这可以通过 z 分数来实现。通过依赖公用表表达式(CTEs)和 SD,我们可以在 jOOQ 中表达以下查询(当然,在生产环境中,使用存储过程可能是此类查询的更好选择):
ctx.with("sales_stats").as(
select(avg(DAILY_ACTIVITY.SALES).as("mean"),
stddevSamp(DAILY_ACTIVITY.SALES).as("sd"))
.from(DAILY_ACTIVITY))
.with("visitors_stats").as(
select(avg(DAILY_ACTIVITY.VISITORS).as("mean"),
stddevSamp(DAILY_ACTIVITY.VISITORS).as("sd"))
.from(DAILY_ACTIVITY))
.select(DAILY_ACTIVITY.DAY_DATE,
abs(DAILY_ACTIVITY.SALES
.minus(field(name("sales_stats", "mean"))))
.divide(field(name("sales_stats", "sd"), Float.class))
.as("z_score_sales"),
abs(DAILY_ACTIVITY.VISITORS
.minus(field(name("visitors_stats", "mean"))))
.divide(field(name("visitors_stats", "sd"), Float.class))
.as("z_score_visitors"))
.from(table("sales_stats"),
table("visitors_stats"), DAILY_ACTIVITY).fetch();
在此查询产生的结果中,我们注意到 2004-01-06 的销售 z 分数为 2.00。在 z 分数分析的情况下,这个输出肯定值得深入调查(通常,z 分数大于 1.96 或小于-1.96 被视为需要进一步调查的异常值)。当然,这并不是我们的目标,所以让我们跳到另一个汇总。
通过对统计汇总的进一步分析,我们得到了方差,它被定义为平均的平方差(即与平均值的平方差的平均值)或平均平方偏差(en.wikipedia.org/wiki/Variance)。在 jOOQ 中,我们通过varSamp()方法获得样本方差,通过varPop()方法获得总体方差,如以下代码示例所示:
Field<BigDecimal> x = PRODUCT.BUY_PRICE;
ctx.select(varSamp(x)) // Sample Variance
.from(PRODUCT).fetch();
ctx.select(varPop(x)) // Population Variance
.from(PRODUCT).fetch();
它们都在 MySQL、PostgreSQL、SQL Server、Oracle 以及许多其他方言中得到支持,但为了好玩,你可以通过以下代码片段中的 COUNT() 和 SUM() 聚合来模拟样本方差——这又是一个练习这些聚合的机会:
ctx.select((count().mul(sum(x.mul(x)))
.minus(sum(x).mul(sum(x)))).divide(count()
.mul(count().minus(1))).as("VAR_SAMP"))
.from(PRODUCT).fetch();
接下来,我们有线性回归(或相关)函数,用于确定因变量(表示为 Y)和自变量(表示为 X)表达式之间的回归关系(en.wikipedia.org/wiki/Regression_analysis)。在 jOOQ 中,我们有 regrSXX()、regrSXY()、regrSYY()、regrAvgX()、regrAvgXY()、regrCount()、regrIntercept()、regrR2() 和 regrSlope()。
例如,在 regrSXY(y, x) 的情况下,y 是因变量表达式,而 x 是自变量表达式。如果 y 是 PRODUCT.BUY_PRICE 而 x 是 PRODUCT.MSRP,那么按 PRODUCT_LINE 进行的线性回归看起来是这样的:
ctx.select(PRODUCT.PRODUCT_LINE,
(regrSXY(PRODUCT.BUY_PRICE, PRODUCT.MSRP)).as("regr_sxy"))
.from(PRODUCT).groupBy(PRODUCT.PRODUCT_LINE).fetch();
列出的函数(包括 regrSXY())在所有方言中都得到支持,但也可以很容易地模拟。例如,regrSXY() 可以模拟为 (SUM(X*Y)-SUM(X) * SUM(Y)/COUNT(*)),如这里所示:
ctx.select(PRODUCT.PRODUCT_LINE,
sum(PRODUCT.BUY_PRICE.mul(PRODUCT.MSRP))
.minus(sum(PRODUCT.BUY_PRICE).mul(sum(PRODUCT.MSRP)
.divide(count()))).as("regr_sxy"))
.from(PRODUCT).groupBy(PRODUCT.PRODUCT_LINE).fetch();
此外,regrSXY() 还可以模拟为 SUM(1) * COVAR_POP(expr1, expr2),其中 COVAR_POP() 代表总体协方差,而 SUM(1) 实际上是 REGR_COUNT(expr1, expr2)。你可以在包含许多其他 REGR_FOO() 函数模拟和通过 regrSlope() 和 regrIntercept() 计算的 y = slope * x – intercept 示例旁边的捆绑代码中看到这个例子,线性回归系数,也可以通过 sum()、avg() 和 max()。
在人口协方差(en.wikipedia.org/wiki/Covariance)之后,我们有样本协方差,COVAR_SAMP(),它可以这样调用:
ctx.select(PRODUCT.PRODUCT_LINE,
covarSamp(PRODUCT.BUY_PRICE, PRODUCT.MSRP).as("covar_samp"),
covarPop(PRODUCT.BUY_PRICE, PRODUCT.MSRP).as("covar_pop"))
.from(PRODUCT)
.groupBy(PRODUCT.PRODUCT_LINE)
.fetch();
如果你的数据库不支持协方差函数(例如,MySQL 或 SQL Server),那么你可以通过常见的聚合来模拟它们——COVAR_SAMP() 作为 (SUM(x*y) - SUM(x) * SUM(y) / COUNT(*)) / (COUNT(*) - 1),而 COVAR_POP() 作为 (SUM(x*y) - SUM(x) * SUM(y) / COUNT(*)) / COUNT(*)。你可以在 AggregateFunctions 包含的代码中找到示例。
一个大多数数据库(Exasol 是其中之一)不支持但由 jOOQ 提供的有趣函数是合成的 product() 函数。此函数表示通过 exp(sum(log(arg))) 对正数进行乘法聚合,并为零和负数执行一些额外的工作。例如,在金融领域,有一个名为复合月增长率(CMGR)的指数,它是基于月收入增长率计算的,正如我们在 SALE.REVENUE_GROWTH 中所做的那样。公式是 (PRODUCT (1 + SALE.REVENUE_GROWTH))) ^ (1 / COUNT()),并且我们在这里为每年的数据应用了它:
ctx.select(SALE.FISCAL_YEAR,
round((product(one().plus(
SALE.REVENUE_GROWTH.divide(100)))
.power(one().divide(count()))).mul(100) ,2)
.concat("%").as("CMGR"))
.from(SALE).groupBy(SALE.FISCAL_YEAR).fetch();
我们还把所有东西乘以 100,以获得作为百分比的输出。您可以在 AggregateFunctions 打包代码中找到此示例,它位于其他聚合函数旁边,如 BOOL_AND()、EVERY()、BOOL_OR() 和位运算函数。
当您必须使用 jOOQ 部分支持或不支持的聚合函数时,您可以使用 aggregate()/ aggregateDistinct() 方法。当然,您的数据库必须支持所调用的聚合函数。例如,jOOQ 不支持 Oracle 的 APPROX_COUNT_DISTINCT() 聚合函数,它是 COUNT (DISTINCT expr) 函数的替代品。这在处理大量数据时非常有用,可以显著快于传统的 COUNT 函数,且与精确数值的偏差可以忽略不计。以下是一个使用 (String name, Class<T> type, Field<?>... arguments) 聚合的示例,这仅仅是提供的一种风味(请参阅文档以获取更多信息):
ctx.select(ORDERDETAIL.PRODUCT_ID,
aggregate("approx_count_distinct", Long.class,
ORDERDETAIL.ORDER_LINE_NUMBER).as("approx_count"))
.from(ORDERDETAIL)
.groupBy(ORDERDETAIL.PRODUCT_ID)
.fetch();
您可以在 Oracle 的 AggregateFunctions 打包代码中找到此示例。
窗口函数
窗口函数非常实用且强大;因此,它们是每个通过 SQL 与数据库交互的开发者必须了解的主题。简而言之,快速概述窗口函数的最好方法是从一个著名的图表开始,该图表展示了聚合函数与窗口函数之间的比较,突出了它们之间的主要区别,如图所示:
![Figure 13.3 – 聚合函数与窗口函数的比较]
![img/B16833_Figure_13.3.jpg]
图 13.3 – 聚合函数与窗口函数的比较
如您所见,聚合函数和窗口函数都在一组行上计算某些内容,但窗口函数不会将这些行聚合或分组成一个单一的输出行。窗口函数依赖于以下语法:
window_function_name (expression) OVER (
Partition Order Frame
)
这个语法可以这样解释:
显然,window_function_name 代表窗口函数的名称,例如 ROW_NUMBER()、RANK() 等等。
expression 用来标识这个窗口函数将要操作的列(或目标表达式)。
OVER 子句表示这是一个窗口函数,它由三个子句组成:Partition、Order 和 Frame。通过向任何聚合函数添加 OVER 子句,您将其转换为窗口函数。
Partition 子句是可选的,其目的是将行划分为分区。接下来,窗口函数将对每个分区进行操作。它的语法如下:PARTITION BY expr1, expr2, ...。如果省略 PARTITION BY,则整个结果集代表一个单一的分区。为了完全准确,如果省略 PARTITION BY,则 FROM/WHERE/GROUP BY/HAVING 产生的所有数据代表一个单一的分区。
Order 子句也是可选的,它处理分区中行的顺序。其语法是 ORDER BY 表达式 [ASC | DESC] [NULLS {FIRST| LAST}] ,...。
Frame 子句定义了当前分区的子集。常见的语法是 mode BETWEEN start_of_frame AND end_of_frame [frame_exclusion]。
mode 指示数据库如何处理输入行。三个可能的值表示框架行与当前行之间的关系类型:ROWS、GROUPS 和 RANGE。
ROWS
ROWS 模式指定框架行和当前行的偏移量是行号(数据库将每个输入行视为一个独立的工作单元)。在此上下文中,start_of_frame 和 end_of_frame 确定窗口框架开始和结束的行。
在此上下文中,start_of_frame 可以是 N PRECEDING,这意味着框架从当前评估行的第 n 行之前开始(在 jOOQ 中,rowsPreceding(n)),UNBOUNDED PRECEDING,这意味着框架从当前分区的第一行开始(在 jOOQ 中,rowsUnboundedPreceding()),以及 CURRENT ROW(jOOQ rowsCurrentRow())。
end_of_frame 的值可以是 CURRENT ROW(之前描述过),N FOLLOWING,这意味着框架在当前评估行的第 n 行之后结束(在 jOOQ 中,rowsFollowing(n)),以及 UNBOUNDED FOLLOWING,这意味着框架在当前分区的最后一行结束(在 jOOQ 中,rowsUnboundedFollowing())。
查看以下包含一些示例的图:

图 13.4 – ROWS 模式示例
灰色部分表示包含的行。
GROUPS
GROUPS 模式指示数据库将具有重复排序值的行分组在一起。因此,当存在重复值时,GROUPS 是有用的。
在此上下文中,start_of_frame 和 end_of_frame 接受与 ROWS 相同的值。但是,在 start_of_frame 的情况下,CURRENT_ROW 指向包含当前行的组中的第一行,而在 end_of_frame 的情况下,它指向包含当前行的组中的最后一行。此外,N PRECEDING/FOLLOWING 指的是应考虑为当前组之前、之后组的数量。另一方面,UNBOUNDED PRECEDING/FOLLOWING 与 ROWS 的情况具有相同的意义。
查看以下包含一些示例的图:

图 13.5 – GROUPS 模式示例
有三个组(G1、G2 和 G3)以不同的灰色阴影表示。
RANGE
RANGE模式不将行绑定为ROWS/GROUPS。此模式在排序列的给定值范围内工作。这次,对于start_of_frame和end_of_frame,我们不指定行/组的数量;相反,我们指定窗口框架应包含的值的最大差异。这两个值必须以与排序列相同的单位(或意义)表示。
在此上下文中,对于start_of_frame,我们有以下内容:(这次,N是排序列的相同单位中的值)N PRECEDING(在 jOOQ 中,rangePreceding(n)),UNBOUNDED PRECEDING(在 jOOQ 中,rangeUnboundedPreceding()),和CURRENT ROW(在 jOOQ 中,rangeCurrentRow())。对于end_of_frame,我们有CURRENT ROW,UNBOUNDED FOLLOWING(在 jOOQ 中,rangeUnboundedFollowing()),N FOLLOWING(在 jOOQ 中,rangeFollowing(n))。
查看以下包含一些示例的图表:

图 13.6 – RANGE 模式示例
灰色部分表示包含的行。
BETWEEN start_of_frame AND end_of_frame
尤其是对于BETWEEN start_of_frame AND end_of_frame结构,jOOQ 提供了fooBetweenCurrentRow(),fooBetweenFollowing(n),fooBetweenPreceding(n),fooBetweenUnboundedFollowing(),和fooBetweenUnboundedPreceding()。在这些方法中,foo可以用rows,groups或range替换。
此外,为了创建复合框架,jOOQ 提供了andCurrentRow(),andFollowing(n),andPreceding(n),andUnboundedFollowing(),和andUnboundedPreceding()。
frame_exclusion
通过frame_exclusion可选部分,我们可以排除窗口框架中的某些行。frame_exclusion在所有三种模式下工作方式完全相同。可能的值在此列出:
-
EXCLUDE CURRENT ROW—排除当前行(在 jOOQ 中,excludeCurrentRow())。 -
EXCLUDE GROUP—排除当前行,同时也排除所有同等级的行(例如,排除所有在排序列中具有相同值的行)。在 jOOQ 中,我们有excludeGroup()方法。 -
EXCLUDE TIES—排除所有同等级的行,但不排除当前行(在 jOOQ 中,excludeTies())。 -
EXCLUDE NO OTHERS—这是默认设置,意味着不排除任何内容(在 jOOQ 中,excludeNoOthers())。
为了更好地可视化这些选项,请查看以下图表:

图 13.7 – 排除行示例
谈到 SQL 中的操作逻辑顺序,我们注意到这里窗口函数位于HAVING和SELECT之间:

图 13.8 – SQL 中的操作逻辑顺序
此外,我认为解释窗口函数可以作用于所有前一步骤生成的数据 1-5,并且可以在所有后续步骤 7-12 中声明(实际上只在 7 和 10 中有效)是有用的。在深入探讨一些窗口函数示例之前,让我们快速了解一下一个不太为人所知但相当有用的 SQL 子句。
QUALIFY 子句
一些数据库(例如,Snowflake)支持一个名为 QUALIFY 的子句。通过这个子句,我们可以过滤(应用谓词)窗口函数的结果。主要的是,SELECT … QUALIFY 子句在窗口函数计算后评估,所以 QUALIFY 后面是 <predicate>,在下面的屏幕截图中,你可以看到它如何产生差异(这个查询通过 ROW_NUMBER() 窗口函数返回 PRODUCT 表的每 10 个产品):

图 13.9 – SQL 中的操作顺序
通过使用 QUALIFY 子句,我们消除了子查询,代码也更简洁。即使这个子句在数据库供应商中具有较差的原生支持,jOOQ 仍然为所有支持的方言模拟它。酷吧?!在本章中,你将看到更多使用 QUALIFY 子句的示例。
使用 ROW_NUMBER()
ROW_NUMBER() 是一个排名窗口函数,它为每一行分配一个顺序号(它从 1 开始)。这里有一个简单的示例:

图 13.10 – ROW_NUMBER() 的简单示例
你已经在 第十二章 的 分页和动态查询 中看到了通过 ROW_NUMBER() 分页数据库视图的示例,所以你应该没有问题理解接下来的两个示例。
假设我们想要计算 PRODUCT.QUANTITY_IN_STOCK 的中位数 (zh.wikipedia.org/wiki/中位数)。在 Oracle 和 PostgreSQL 中,这可以通过内置的 median() 聚合函数完成,但在 MySQL 和 SQL Server 中,我们必须以某种方式模拟它,而一种好的方法就是使用 ROW_NUMBER(),如下所示:
Field<Integer> x = PRODUCT.QUANTITY_IN_STOCK.as("x");
Field<Double> y = inline(2.0d).mul(rowNumber().over()
.orderBy(PRODUCT.QUANTITY_IN_STOCK))
.minus(count().over()).as("y");
ctx.select(avg(x).as("median")).from(select(x, y)
.from(PRODUCT))
.where(y.between(0d, 2d))
.fetch();
这很简单!接下来,让我们尝试解决不同类型的问题,并关注 ORDER 表。每个订单都有一个 REQUIRED_DATE 和 STATUS 值,如 Shipped、Cancelled 等。假设我们想看到由连续时间段表示的集群(也称为岛屿),其中分数(在这种情况下为 STATUS)保持不变。一个输出样本如下所示:

图 13.11 – 集群
如果我们需要通过 ROW_NUMBER() 解决这个问题并在 jOOQ 中表达它,那么我们可能会提出这个查询:
Table<?> t = select(
ORDER.REQUIRED_DATE.as("rdate"), ORDER.STATUS.as("status"),
(rowNumber().over().orderBy(ORDER.REQUIRED_DATE)
.minus(rowNumber().over().partitionBy(ORDER.STATUS)
.orderBy(ORDER.REQUIRED_DATE))).as("cluster_nr"))
.from(ORDER).asTable("t");
ctx.select(min(t.field("rdate")).as("cluster_start"),
max(t.field("rdate")).as("cluster_end"),
min(t.field("status")).as("cluster_score"))
.from(t)
.groupBy(t.field("cluster_nr"))
.orderBy(1)
.fetch();
你可以在捆绑的 RowNumber 代码中练习这些示例。
使用 RANK()
RANK()是一个排名窗口函数,它为结果集分区内的每一行分配一个排名。行的排名计算为1 + 它之前的排名数量。具有相同值的列获得相同的排名;因此,如果有多个行具有相同的排名,则下一行的排名不是连续的。想象一下一场比赛中两位运动员共享第一名(或金牌)而没有第二名(因此没有银牌)的情况。这里提供了一个简单的示例:

图 13.14 – 输出
最后,让我们使用DENSE_RANK()来选择每个办公室的最高薪水,包括重复项。这次,让我们也使用QUALIFY子句。代码如下所示:
select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
EMPLOYEE.SALARY, OFFICE.CITY, OFFICE.COUNTRY,
OFFICE.OFFICE_CODE)
.from(EMPLOYEE)
.innerJoin(OFFICE)
.on(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE))
.qualify(denseRank().over().partitionBy(OFFICE.OFFICE_CODE)
.orderBy(EMPLOYEE.SALARY.desc()).eq(1))
.fetch();
在继续之前,这里有一篇不错的阅读材料:blog.jooq.org/2014/08/12/the-difference-between-row_number-rank-and-dense_rank/。您可以在DenseRank捆绑代码中查看这些示例。
使用 PERCENT_RANK()
PERCENT_RANK()窗口函数计算结果集中行的百分位数排名((rank - 1) / (total_rows - 1)),并返回介于 0(不包括)和 1(包括)之间的值。结果集中的第一行始终具有等于 0 的百分排名。此函数不计NULL值,并且是非确定的。通常,最终结果乘以 100 以表示为百分比。
通过示例了解此函数的最佳方式。假设我们想要计算每个办公室员工的薪资百分位排名。用 jOOQ 表达的查询将如下所示:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
EMPLOYEE.SALARY, OFFICE.OFFICE_CODE, OFFICE.CITY,
OFFICE.COUNTRY, round(percentRank().over()
.partitionBy(OFFICE.OFFICE_CODE)
.orderBy(EMPLOYEE.SALARY).mul(100), 2)
.concat("%").as("PERCENTILE_RANK"))
.from(EMPLOYEE)
.innerJoin(OFFICE)
.on(EMPLOYEE.OFFICE_CODE.eq(OFFICE.OFFICE_CODE))
.fetch();
以下截图表示结果的一个片段:

图 13.15 – 百分位排名输出
那么,我们如何解释这个输出?百分位排名通常定义为在分布中,某个结果(或分数)大于或等于(有时仅大于)的结果(或分数)的比例。例如,如果你在某个测试中得到了 90 分,而这个分数大于(或等于)参加测试的 75% 的参与者的分数,那么你的百分位排名是 75。你将处于第 75 个百分位。
换句话说,在办公室 1 中,我们可以说 40% 的员工薪资低于 Anthony Bow(查看第三行),因此 Anthony Bow 处于第 40 个百分位。同样,在办公室 1 中,Diane Murphy 的薪资最高,因为 100% 的员工薪资低于她的薪资(查看第六行)。当当前行是分区中的第一行时,没有先前数据需要考虑,因此百分位排名为 0。一个有趣的案例是 George Vanauf(最后一行),其百分位排名为 0%。因为他的薪资($55,000)与 Foon Yue Tseng 的薪资相同,我们可以这样说,没有人有比他更低的薪资。
PERCENT_RANK() 函数的一个常见用途是将数据分类到自定义组(也称为自定义分箱)。例如,让我们考虑我们想要计算利润低(低于第 20 个百分位数)、中等(介于第 20 个和第 80 个百分位数之间)和高(高于第 80 个百分位数)的部门。以下是计算此数据的代码:
ctx.select(count().filterWhere(field("p").lt(0.2))
.as("low_profit"),
count().filterWhere(field("p").between(0.2, 0.8))
.as("good_profit"),
count().filterWhere(field("p").gt(0.8))
.as("high_profit"))
.from(select(percentRank().over()
.orderBy(DEPARTMENT.PROFIT).as("p"))
.from(DEPARTMENT)
.where(DEPARTMENT.PROFIT.isNotNull()))
.fetch();
你可以在 PercentRank 包含的代码中练习这些示例——以及更多。
使用 CUME_DIST()
CUME_DIST() 是一个窗口函数,它计算值在值集中的累积分布。换句话说,CUME_DIST() 将具有值的行数除以当前行值的总行数。返回的值大于零且小于或等于一(0 < CUME_DIST() <= 1)。具有重复值的列将获得相同的 CUME_DIST() 值。这里提供了一个简单的示例:

图 13.16 – CUME_DIST() 的简单示例
因此,我们有一个包含 23 行的结果集。对于第一行(标记为 A),CUME_DIST() 函数找到值小于或等于 50000 的行数。结果是 4。然后,该函数将 4 除以总行数,即 23:4/23。结果是 0.17 或 17%。相同的逻辑应用于下一行。
如何获取 2003 年和 2004 年销售的前 25%?这可以通过CUME_DIST()和方便的QUALIFY子句来解决,如下所示:
ctx.select(concat(EMPLOYEE.FIRST_NAME, inline(" "),
EMPLOYEE.LAST_NAME).as("name"), SALE.SALE_, SALE.FISCAL_YEAR)
.from(EMPLOYEE)
.join(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER)
.and(SALE.FISCAL_YEAR.in(2003, 2004)))
.qualify(cumeDist().over().partitionBy(SALE.FISCAL_YEAR)
.orderBy(SALE.SALE_.desc()).lt(BigDecimal.valueOf(0.25)))
.fetch();
你可以在捆绑的CumeDist代码中练习这些示例。
使用 LEAD()/LAG()
LEAD()是一个窗口函数,它向前查看指定数量的行(默认为 1)并从当前行访问该行。LAG()与LEAD()的工作方式相同,但它向后查看。对于这两个函数,我们可以选择指定一个默认值,当没有后续行(LEAD())或没有前导行(LAG())时返回该默认值而不是返回NULL。这里提供了一个简单的示例:

图 13.17 – LEAD()和 LAG()的简单示例
除了在这个示例中使用的lead/lag(Field语法外,jOOQ 还公开了lead/lag(Field、lead/lag(Field和lead/lag(Field<T> field, int offset, T defaultValue)。在这个示例中,lead/lag(ORDER.ORDER_DATE)使用偏移量 1,所以与lead/lag(ORDER.ORDER_DATE, 1)是同一件事。
这里有一个示例,对于每个员工,使用办公室作为LEAD()的分区来显示工资和下一份工资。当LEAD()达到分区的末尾时,我们使用 0 而不是NULL:
ctx.select(OFFICE.OFFICE_CODE, OFFICE.CITY, OFFICE.COUNTRY,
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME, EMPLOYEE.SALARY,
lead(EMPLOYEE.SALARY, 1, 0).over()
.partitionBy(OFFICE.OFFICE_CODE)
.orderBy(EMPLOYEE.SALARY).as("next_salary"))
.from(OFFICE)
.innerJoin(EMPLOYEE)
.on(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE))
.fetch();
接下来,让我们解决一个计算月度(MOM)增长率的示例。这个财务指标对于基准测试业务非常有用,我们已经在SALE.REVENUE_GROWTH列中有了它。但这里是通过LAG()函数计算 2004 年*的查询:
ctx.select(SALE.FISCAL_MONTH,
inline(100).mul((SALE.SALE_.minus(lag(SALE.SALE_, 1)
.over().orderBy(SALE.FISCAL_MONTH)))
.divide(lag(SALE.SALE_, 1).over()
.orderBy(SALE.FISCAL_MONTH))).concat("%").as("MOM"))
.from(SALE)
.where(SALE.FISCAL_YEAR.eq(2004))
.orderBy(SALE.FISCAL_MONTH)
.fetch();
对于更多示例,包括关于漏斗流失指标和时间序列分析的示例,请查看捆绑的LeadLag代码。
使用 NTILE()
NTILE(n)是一个窗口函数,通常用于将指定n个组或桶中的行数进行分配。每个桶都有一个数字(从 1 开始),表示该行属于哪个桶。这里提供了一个简单的示例:

图 13.18 – NTILE()的简单示例
因此,在这个示例中,我们将EMPLOYEE.SALARY分布在 10 个桶中。NTILE()努力确定每个桶应该有多少行,以便提供桶的数量并使它们大致相等。
在其用例中,NTILE()对于计算最近度、频率和货币(RFM)指数非常有用(en.wikipedia.org/wiki/RFM_(market_research)). 简而言之,RFM 分析基本上是一种索引技术,它依赖于过去的购买行为来确定不同的客户细分市场。
在我们的案例中,每个客户的过去购买行为(ORDER.CUSTOMER_NUMBER)存储在ORDER表中,特别是在ORDER.ORDER_ID、ORDER.ORDER_DATE和ORDER.AMOUNT中。
根据这些信息,我们尝试根据 R、F 和 M 的值分布将客户分为四个相等的组。RFM 变量上的四个相等组产生 43=64 个潜在细分市场。结果是一个表格,其中每个分位数(R、F 和 M)都有一个介于 1 和 4 之间的分数。查询本身就可以说明一切,如下所示:
ctx.select(field("customer_number"),
ntile(4).over().orderBy(field("last_order_date"))
.as("rfm_recency"),
ntile(4).over().orderBy(field("count_order"))
.as("rfm_frequency"),
ntile(4).over().orderBy(field("avg_amount"))
.as("rfm_monetary")).from(
select(ORDER.CUSTOMER_NUMBER.as("customer_number"),
max(ORDER.ORDER_DATE).as("last_order_date"),
count().as("count_order"),
avg(ORDER.AMOUNT).as("avg_amount"))
.from(ORDER)
.groupBy(ORDER.CUSTOMER_NUMBER))
.fetch();
这里提供了一个示例输出:

图 13.19 – RFM 示例
通过将 RFM 结果组合为 R100+F10+M,我们可以获得一个综合评分。这在Ntile捆绑代码中的更多示例旁边可用。
使用 FIRST_VALUE()和 LAST_VALUE()
FIRST_VALUE(expr)返回相对于窗口帧中第一行的指定表达式(expr)的值。
NTH_VALUE(expr, offset)返回相对于窗口帧中偏移行指定的表达式(expr)的值。
LAST_VALUE(expr)返回相对于窗口帧中最后一行的指定表达式(expr)的值。
假设我们的目标是获取每个产品线的最便宜和最贵的产品,如下面的截图所示:

图 13.20 – 每个产品线的最便宜和最贵的产品
通过FIRST_VALUE()和LAST_VALUE()完成此任务可以这样做:
ctx.select(PRODUCT.PRODUCT_LINE,
PRODUCT.PRODUCT_NAME, PRODUCT.BUY_PRICE,
firstValue(PRODUCT.PRODUCT_NAME).over()
.partitionBy(PRODUCT.PRODUCT_LINE)
.orderBy(PRODUCT.BUY_PRICE).as("cheapest"),
lastValue(PRODUCT.PRODUCT_NAME).over()
.partitionBy(PRODUCT.PRODUCT_LINE)
.orderBy(PRODUCT.BUY_PRICE)
.rangeBetweenUnboundedPreceding()
.andUnboundedFollowing().as("most_expensive"))
.from(PRODUCT)
.fetch();
如果未指定窗口帧,则默认窗口帧取决于ORDER BY的存在。如果存在ORDER BY,则窗口帧为RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。如果不存在ORDER BY,则窗口帧为RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING。
考虑到这一点,在我们的情况下,FIRST_VALUE()可以依赖于默认的窗口帧来返回分区中的第一行,即最低价格。另一方面,LAST_VALUE()必须明确定义窗口帧为RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING以返回最高价格。
这里是另一个通过NTH_VALUE()获取每个产品线的第二贵产品的示例:
ctx.select(PRODUCT.PRODUCT_LINE,
PRODUCT.PRODUCT_NAME, PRODUCT.BUY_PRICE,
nthValue(PRODUCT.PRODUCT_NAME, 2).over()
.partitionBy(PRODUCT.PRODUCT_LINE)
.orderBy(PRODUCT.BUY_PRICE.desc())
.rangeBetweenUnboundedPreceding()
.andUnboundedFollowing().as("second_most_expensive"))
.from(PRODUCT)
.fetch();
前面的查询按降序对BUY_PRICE进行排序,以获取每个产品线的第二贵产品。但这主要是从底部开始的第二行,因此我们可以依赖于FROM LAST子句(在 jOOQ 中为fromLast())来表达它,如下所示:
ctx.select(PRODUCT.PRODUCT_LINE,
PRODUCT.PRODUCT_NAME, PRODUCT.BUY_PRICE,
nthValue(PRODUCT.PRODUCT_NAME, 2).fromLast().over()
.partitionBy(PRODUCT.PRODUCT_LINE)
.orderBy(PRODUCT.BUY_PRICE)
.rangeBetweenUnboundedPreceding()
.andUnboundedFollowing().as("second_most_expensive"))
.from(PRODUCT)
.fetch();
此查询在 Oracle 中运行良好,Oracle 支持FROM FIRST (fromFirst()), FROM LAST (fromLast()), IGNORE NULLS (ignoreNulls()), 和 RESPECT NULLS (respectNulls()).
你可以在FirstLastNth捆绑代码中练习这些示例。
使用 RATIO_TO_REPORT()
RATIO_TO_REPORT(expr)计算指定值与集合中值的总和的比率。如果给定的expr值评估为null,则此函数返回null。这里提供了一个简单的示例:

图 13.21 – RATIO_TO_REPORT()的简单示例
例如,对于第一行,比率为 51241.54 / 369418.38,其中 369418.38 是所有销售额的总和。在应用round()函数后,结果为 0.14 或 14%,但如果我们想计算当前财年销售额的比率,可以通过PARTITION BY来实现,如下所示:
ctx.select(SALE.EMPLOYEE_NUMBER, SALE.FISCAL_YEAR, SALE.SALE_,
round(ratioToReport(SALE.SALE_).over()
.partitionBy(SALE.FISCAL_YEAR), 2)
.as("ratio_to_report_sale"))
.from(SALE).fetch();
让我们计算每个员工当前工资总和的比率,并以百分比的形式表示,如下所示:
ctx.select(OFFICE.OFFICE_CODE,
sum(EMPLOYEE.SALARY).as("salaries"),
ratioToReport(sum(EMPLOYEE.SALARY)).over()
.mul(100).concat("%").as("ratio_to_report"))
.from(OFFICE)
.join(EMPLOYEE)
.on(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE))
.groupBy(OFFICE.OFFICE_CODE)
.orderBy(OFFICE.OFFICE_CODE)
.fetch();
您可以在捆绑的RatioToReport代码中查看这些示例。
聚合函数作为窗口函数
聚合函数也可以用作窗口函数。例如,让我们使用SUM()聚合函数作为窗口函数来计算每个客户在每个缓存日期成功转移的金额总和,如下面的截图所示:

图 13.22 – 到每个缓存日期为止转移金额的总和
jOOQ 查询可以表达如下:
ctx.select(BANK_TRANSACTION.CUSTOMER_NUMBER,
BANK_TRANSACTION.CACHING_DATE,
BANK_TRANSACTION.TRANSFER_AMOUNT, BANK_TRANSACTION.STATUS,
sum(BANK_TRANSACTION.TRANSFER_AMOUNT).over()
.partitionBy(BANK_TRANSACTION.CUSTOMER_NUMBER)
.orderBy(BANK_TRANSACTION.CACHING_DATE)
.rowsBetweenUnboundedPreceding().andCurrentRow().as("result"))
.from(BANK_TRANSACTION)
.where(BANK_TRANSACTION.STATUS.eq("SUCCESS")).fetch();
或者,让我们使用AVG()聚合函数作为窗口函数来计算每个订单前三个排序产品的平均价格,如下面的截图所示:

图 13.23 – 每个订单前三个排序产品的平均价格
查询看起来是这样的:
ctx.select(ORDERDETAIL.ORDER_ID, ORDERDETAIL.PRODUCT_ID, ...,
avg(ORDERDETAIL.PRICE_EACH).over()
.partitionBy(ORDERDETAIL.ORDER_ID)
.orderBy(ORDERDETAIL.PRICE_EACH)
.rowsPreceding(2).as("avg_prec_3_prices"))
.from(ORDERDETAIL).fetch();
关于计算运行平均口味的事情——换句话说,创建一个报告,显示 2005 年 3 月Visa Electron卡上的每笔交易?此外,此报告显示基于 3 天移动平均的每日平均交易金额。完成此操作的代码如下所示:
ctx.select(
BANK_TRANSACTION.CACHING_DATE, BANK_TRANSACTION.CARD_TYPE,
sum(BANK_TRANSACTION.TRANSFER_AMOUNT).as("daily_sum"),
avg(sum(BANK_TRANSACTION.TRANSFER_AMOUNT)).over()
.orderBy(BANK_TRANSACTION.CACHING_DATE)
.rowsBetweenPreceding(2).andCurrentRow()
.as("transaction_running_average"))
.from(BANK_TRANSACTION)
.where(BANK_TRANSACTION.CACHING_DATE
.between(LocalDateTime.of(2005, 3, 1, 0, 0, 0),
LocalDateTime.of(2005, 3, 31, 0, 0, 0))
.and(BANK_TRANSACTION.CARD_TYPE.eq("VisaElectron")))
.groupBy(BANK_TRANSACTION.CACHING_DATE,
BANK_TRANSACTION.CARD_TYPE)
.orderBy(BANK_TRANSACTION.CACHING_DATE).fetch();
如卢卡斯·埃德(Lukas Eder)所说:“关于聚合窗口函数最令人震惊的是,甚至用户定义的聚合函数也可以用作窗口函数!”
您可以在捆绑的PostgreSQL代码中查看更多示例(例如,您可以找到关于有多少其他员工的薪水与我相同?和有多少销售额比 5000 或更少?的查询),在AggregateWindowFunctions捆绑代码中。
聚合函数和 ORDER BY
某些聚合函数根据它们的输入顺序输出显著不同的结果。默认情况下,这种排序没有指定,但可以通过可选的ORDER BY子句作为参数来控制。因此,在这些聚合函数调用中存在ORDER BY时,我们可以获取排序后的聚合结果。让我们看看如何在 jOOQ 中使用这些函数,并从以AGG结尾的函数类别开始,例如ARRAY_AGG()、JSON_ARRAYAGG()、XML_AGG()、MULTISET_AGG()(在第八章,获取和映射)等。
FOO_AGG()
例如,ARRAY_AGG() 是一个将数据聚合到数组中的函数,在存在 ORDER BY 的情况下,它将数据聚合到符合指定顺序的数组中。以下是一个使用 ARRAY_AGG() 按降序聚合 EMPLOYEE.FIRST_NAME 和 EMPLOYEE.LAST_NAME 的示例:
ctx.select(arrayAgg(EMPLOYEE.FIRST_NAME).orderBy(
EMPLOYEE.FIRST_NAME.desc(), EMPLOYEE.LAST_NAME.desc()))
.from(EMPLOYEE).fetch();
对于 PostgreSQL,jOOQ 生成的 SQL 如下:
SELECT ARRAY_AGG(
"public"."employee"."first_name"
ORDER BY
"public"."employee"."first_name" DESC,
"public"."employee"."last_name" DESC
) FROM "public"."employee"
结果是一个数组,如 [Yoshimi,William,Tom,Steve,Peter,…],被包装为 Result<Record1<String[]>>(通过 get(0).value1() 提取 String[])。不要将 ARRAY_AGG() 与 jOOQ 的 fetchArray() 混淆。在 ARRAY_AGG() 的情况下,数组是由数据库构建的,而在 fetchArray() 的情况下,数组是在获取结果集后由 jOOQ 构建的。
另外两个接受 ORDER BY 的聚合函数是 JSON_ARRAYAGG() 和 XML_AGG()。你应该熟悉这些函数来自 第八章,获取和映射,但你也可以在本节附带代码中看到几个简单的示例。
COLLECT()
一个接受 ORDER BY 的有趣方法是 Oracle 的 COLLECT() 方法。虽然 ARRAY_AGG() 代表了将数据聚合到数组中的标准 SQL 函数,但 COLLECT() 函数是 Oracle 特有的,并生成一个结构化类型的数组。让我们假设以下 Oracle 用户定义的类型:
CREATE TYPE "SALARY_ARR" AS TABLE OF NUMBER(7);
jOOQ 代码生成器将为这个用户定义的类型生成 SalaryArrRecord 类,位于 jooq.generated.udt.records 中。通过这个 UDT 记录,我们可以按薪资降序和按职位升序收集员工的薪资,如下所示:
var result = ctx.select(
collect(EMPLOYEE.SALARY, SalaryArrRecord.class)
.orderBy(EMPLOYEE.SALARY.asc(), EMPLOYEE.JOB_TITLE.desc()))
.from(EMPLOYEE).fetch();
jOOQ 通过以下 SQL 获取 Result<Record1<SalaryArrRecord>>:
SELECT CAST(COLLECT(
"CLASSICMODELS"."EMPLOYEE"."SALARY"
ORDER BY
"CLASSICMODELS"."EMPLOYEE"."SALARY" ASC,
"CLASSICMODELS"."EMPLOYEE"."JOB_TITLE" DESC)
AS "CLASSICMODELS"."SALARY_ARR")
FROM "CLASSICMODELS"."EMPLOYEE"
通过调用 get(0).value1().toArray(Integer[]::new),你可以访问薪资数组。或者,通过调用 get(0).value1().get(5),你可以访问第五个薪资。依赖 fetchOneInto()/fetchSingleInto() 也是一种选择,如下所示:
SalaryArrRecord result = ctx.select(
collect(EMPLOYEE.SALARY, SalaryArrRecord.class)
.orderBy(EMPLOYEE.SALARY.asc(), EMPLOYEE.JOB_TITLE.desc()))
.from(EMPLOYEE)
.fetchOneInto(SalaryArrRecord.class);
现在,你可以通过 result.toArray(Integer[]::new) 访问薪资数组,并通过 result.get(5) 访问第五个薪资。
GROUP_CONCAT()
另一个接受 ORDER BY 子句的酷炫聚合函数是 GROUP_CONCAT() 函数(在 MySQL 中非常流行),用于获取字段的聚合拼接结果。jOOQ 在 Oracle、PostgreSQL、SQL Server 以及其他不支持原生此功能的方言中模拟了这个函数。
例如,让我们使用 GROUP_CONCAT() 来获取按薪资降序排列的员工姓名的字符串,如下所示:
ctx.select(groupConcat(concat(EMPLOYEE.FIRST_NAME,
inline(" "), EMPLOYEE.LAST_NAME))
.orderBy(EMPLOYEE.SALARY.desc()).separator(";")
.as("names_of_employees"))
.from(EMPLOYEE).fetch();
输出将类似于这样:Diane Murphy;Mary Patterson;Jeff Firrelli;…。
Oracle 的 KEEP() 子句
这是一点快速提示——你在查询中看到过这样的聚合函数吗:SUM(some_value) KEEP (DENSE_RANK FIRST ORDER BY some_date)?或者这个分析变体:SUM(some_value) KEEP (DENSE_RANK LAST ORDER BY some_date) OVER (PARTITION BY some_partition)?
如果你做到了,那么你知道你所看到的是 Oracle 的 KEEP() 子句在起作用,或者换句话说,是 SQL 的 FIRST() 和 LAST() 函数,由 KEEP() 子句前缀以增加语义清晰度,以及 DENSE_RANK() 以指示 Oracle 应该仅对奥运排名(那些具有最大(LAST())或最小(FIRST())密集排名的行)进行聚合,分别后缀 ORDER BY() 和可选的 OVER(PARTITION BY())。LAST() 和 FIRST() 都可以被视为聚合函数(如果你省略了 OVER() 子句)或作为分析函数。
但让我们基于 CUSTOMER 和 ORDER 表来设定一个场景。每个客户 (CUSTOMER.CUSTOMER_NUMBER) 都有一个或多个订单,假设我们想要为每个 CUSTOMER 类型获取最接近 2004 年 6 月 6 日(或任何其他日期,包括当前日期)的 ORDER.ORDER_DATE 值。这可以通过查询轻松实现,如下所示:
ctx.select(ORDER.CUSTOMER_NUMBER, max(ORDER.ORDER_DATE))
.from(ORDER)
.where(ORDER.ORDER_DATE.lt(LocalDate.of(2004, 6, 6)))
.groupBy(ORDER.CUSTOMER_NUMBER)
.fetch();
顺便选择 ORDER.SHIPPED_DATE 和 ORDER.STATUS 好吗?一种方法可能是依赖 ROW_NUMBER() 窗口函数和 QUALIFY() 子句,如下所示:
ctx.select(ORDER.CUSTOMER_NUMBER, ORDER.ORDER_DATE,
ORDER.SHIPPED_DATE, ORDER.STATUS)
.from(ORDER)
.where(ORDER.ORDER_DATE.lt(LocalDate.of(2004, 6, 6)))
.qualify(rowNumber().over()
.partitionBy(ORDER.CUSTOMER_NUMBER)
.orderBy(ORDER.ORDER_DATE.desc()).eq(1))
.fetch();
如你在捆绑的代码中所见,另一种方法可能是依赖 SELECT DISTINCT ON(如 Twitter 上的 @dmitrygusev 建议的)或反连接,但如果我们为 Oracle 编写查询,那么你很可能会选择 KEEP() 子句,如下所示:
ctx.select(ORDER.CUSTOMER_NUMBER,
max(ORDER.ORDER_DATE).as("ORDER_DATE"),
max(ORDER.SHIPPED_DATE).keepDenseRankLastOrderBy(
ORDER.SHIPPED_DATE).as("SHIPPED_DATE"),
max(ORDER.STATUS).keepDenseRankLastOrderBy(
ORDER.SHIPPED_DATE).as("STATUS"))
.from(ORDER)
.where(ORDER.ORDER_DATE.lt(LocalDate.of(2004, 6, 6)))
.groupBy(ORDER.CUSTOMER_NUMBER).fetch();
或者,你也可以通过利用 Oracle 的 ROWID 伪列来实现,如下所示:
ctx.select(ORDER.CUSTOMER_NUMBER, ORDER.ORDER_DATE,
ORDER.SHIPPED_DATE, ORDER.STATUS)
.from(ORDER)
.where((rowid().in(select(max((rowid()))
.keepDenseRankLastOrderBy(ORDER.SHIPPED_DATE))
.from(ORDER)
.where(ORDER.ORDER_DATE.lt(LocalDate.of(2004, 6, 6)))
.groupBy(ORDER.CUSTOMER_NUMBER)))).fetch();
你可以在捆绑的代码 AggregateFunctionsOrderBy 中练习这些示例。
有序集聚合函数(WITHIN GROUP)
有序集聚合函数 允许通过 WITHIN GROUP 子句对排序后的行集进行操作。通常,这些函数用于执行依赖于特定行排序的计算。在这里,我们可以快速提及 假设集 函数,如 RANK()、DENSE_RANK()、PERCENT_RANK() 或 CUME_DIST(),以及 逆分布函数,如 PERCENTILE_CONT()、PERCENTILE_DISC() 或 MODE()。一个特殊情况由 LISTAGG() 表示,它在本节的末尾进行介绍。
假设集函数
假设集函数为假设值(让我们用 hv 表示)计算某些内容。在这个上下文中,DENSE_RANK() 计算没有间隔的 hv 排名,而 RANK() 做同样的事情但带有间隔。CUME_DIST() 计算累积分布(hv 的相对排名从 1/n 到 1),而 PERCENT_RANK() 计算百分比排名(hv 的相对排名从 0 到 1)。
例如,假设我们想要计算假设值(2004,10000)的排名,其中2004是SALE.FISCAL_YEAR,10000是SALE.SALE_。接下来,对于现有数据,我们想要获得所有小于这个假设值排名的无间隙排名。对于问题的第一部分,我们依赖于DENSE_RANK()假设集函数,而对于第二部分,依赖于DENSE_RANK()窗口函数,如下所示:
ctx.select(SALE.EMPLOYEE_NUMBER, SALE.FISCAL_YEAR, SALE.SALE_)
.from(SALE)
.qualify(denseRank().over()
.orderBy(SALE.FISCAL_YEAR.desc(), SALE.SALE_)
.le(select(denseRank(val(2004), val(10000))
.withinGroupOrderBy(SALE.FISCAL_YEAR.desc(), SALE.SALE_))
.from(SALE))).fetch();
现在,让我们考虑另一个使用PERCENT_RANK()假设集函数的例子。这次,假设我们计划为新销售代表提供$61,000的薪水,但在做这件事之前,我们想知道有多少比例的当前销售代表的薪水高于$61,000。这可以这样完成:
ctx.select(count().as("nr_of_salaries"),
percentRank(val(61000d)).withinGroupOrderBy(
EMPLOYEE.SALARY.desc()).mul(100).concat("%")
.as("salary_percentile_rank"))
.from(EMPLOYEE)
.where(EMPLOYEE.JOB_TITLE.eq("Sales Rep")).fetch();
此外,我们还想了解薪水高于$61,000的销售代表的百分比。为此,我们需要不同的薪水,如下所示:
ctx.select(count().as("nr_of_salaries"),
percentRank(val(61000d)).withinGroupOrderBy(
field(name("t", "salary")).desc()).mul(100).concat("%")
.as("salary_percentile_rank"))
.from(selectDistinct(EMPLOYEE.SALARY.as("salary"))
.from(EMPLOYEE)
.where(EMPLOYEE.JOB_TITLE.eq("Sales Rep"))
.asTable("t"))
.fetch();
你可以在OrderedSetAggregateFunctions捆绑代码旁边练习这些例子,以及其他RANK()和CUME_DIST()假设集函数。
逆分布函数
简而言之,逆分布函数计算百分位数。有两种分布模型:一种离散模型(通过PERCENTILE_DISC()计算)和一种连续模型(通过PERCENTILE_CONT()计算)。
PERCENTILE_DISC()和PERCENTILE_CONT()
但计算百分位数实际上意味着什么呢?粗略地说,考虑一个特定的百分比,P(这个百分比是一个介于 0(包含)和 1(包含)之间的浮点值),以及一个排序字段,F。在这种情况下,百分位数的计算表示低于F值百分比的值。
例如,让我们考虑SALES表,并希望找到第 25 百分位的销售额。在这种情况下,P = 0.25,排序字段是SALE.SALE_。应用PERCENTILE_DISC()和PERCENTILE_CONT()函数的结果如下:
ctx.select(
percentileDisc(0.25)
.withinGroupOrderBy(SALE.SALE_).as("pd - 0.25"),
percentileCont(0.25)
.withinGroupOrderBy(SALE.SALE_).as("pc - 0.25"))
.from(SALE)
.fetch();
在捆绑的代码中,你可以看到这个查询扩展到了 50th、75th 和 100th 百分位数。得到的值(例如,2974.43)表示低于该值的销售额占销售额的 25%。在这种情况下,PERCENTILE_DISC()和PERCENTILE_CONT()返回相同的值(2974.43),但这并不总是如此。记住,PERCENTILE_DISC()在离散模型上工作,而PERCENTILE_CONT()在连续模型上工作。换句话说,如果销售(也称为总体)中没有值(销售额)正好落在指定的百分位数,PERCENTILE_CONT()必须假设连续分布进行插值。基本上,PERCENTILE_CONT()从两个紧接在所需值之后和之前的值(销售额)中插值所需的值(销售额)。例如,如果我们重复之前的例子来计算 11th 百分位数,那么PERCENTILE_DISC()返回 1676.14,这是一个存在的销售额,而PERCENTILE_CONT()返回 1843.88,这是一个插值值,该值在数据库中不存在。
虽然 Oracle 支持 PERCENTILE_DISC() 和 PERCENTILE_CONT() 作为排序集聚合函数和窗口函数变体,但 PostgreSQL 只支持它们作为排序集聚合函数,SQL Server 只支持窗口函数变体,MySQL 完全不支持它们。模拟它们并不简单,但 Lukas Eder 的这篇优秀文章是这方面的必读之作:blog.jooq.org/2019/01/28/how-to-emulate-percentile_disc-in-mysql-and-other-rdbms/。
现在,让我们看看使用 PERCENTILE_DISC() 作为窗口函数变体和 PERCENTILE_CONT() 作为排序集聚合函数的示例。这次,重点是 EMPLOYEE.SALARY。首先,我们想通过 PERCENTILE_DISC() 计算每个办公室的薪资的 50 分位数。其次,我们只想保留那些小于通过 PERCENTILE_CONT() 计算的总体 50 分位数的 50 分位数。代码如下所示:
ctx.select().from(
select(OFFICE.OFFICE_CODE, OFFICE.CITY, OFFICE.COUNTRY,
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME, EMPLOYEE.SALARY,
percentileDisc(0.5).withinGroupOrderBy(EMPLOYEE.SALARY)
.over().partitionBy(OFFICE.OFFICE_CODE)
.as("percentile_disc"))
.from(OFFICE)
.join(EMPLOYEE)
.on(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE)).asTable("t"))
.where(field(name("t", "percentile_disc"))
.le(select(percentileCont(0.5)
.withinGroupOrderBy(EMPLOYEE.SALARY))
.from(EMPLOYEE))).fetch();
你可以在捆绑的 OrderedSetAggregateFunctions 代码中练习这些示例。
MODE() 函数
主要来说,MODE() 函数作用于一系列值以产生一个结果(称为 众数),表示出现频率最高的值。MODE() 函数有两种形式,如下所述:
-
MODE(field)聚合函数 -
MODE WITHIN GROUP (ORDER BY [order clause])排序集聚合函数
如果有多个结果(众数)可用,那么 MODE() 只返回一个值。如果有给定的排序,那么将选择第一个值。
MODE() 聚合函数在 PostgreSQL 和 Oracle 中由 jOOQ 模拟,但在 MySQL 和 SQL Server 中不支持。例如,假设我们想找出一年中销售量最多的月份,为此,我们可能会提出以下查询(注意,不允许为 MODE() 明确使用 ORDER BY 子句):
ctx.select(mode(SALE.FISCAL_MONTH).as("fiscal_month"))
.from(SALE).fetch();
在 PostgreSQL 中运行此查询会显示 jOOQ 通过排序集聚合函数模拟了 MODE() 聚合函数,该函数由 PostgreSQL 支持:
SELECT MODE() WITHIN GROUP (ORDER BY
"public"."sale"."fiscal_month") AS "fiscal_month"
FROM "public"."sale"
在这种情况下,如果有多个众数可用,那么将返回第一个,按照升序排列。另一方面,对于 Oracle 的情况,jOOQ 使用 STATS_MODE() 函数,如下所示:
SELECT
STATS_MODE("CLASSICMODELS"."SALE"."FISCAL_MONTH")
"fiscal_month" FROM "CLASSICMODELS"."SALE"
在以下情况下,生成的 SQL 中没有排序,如果有多个众数可用,那么只返回一个。另一方面,MODE() 排序集聚合函数只由 PostgreSQL 支持:
ctx.select(mode().withinGroupOrderBy(
SALE.FISCAL_MONTH.desc()).as("fiscal_month"))
.from(SALE).fetch();
如果有多个结果(众数)可用,那么 MODE() 只返回一个值,表示最高值(在这个特定情况下,最接近十二月(包括十二月)的月份)因为我们使用了降序排列。
尽管如此,如果存在多个模式(如果有更多的话),如何返回所有模式?通常,统计学家在存在两个模式时称之为双峰分布,存在三个模式时称之为三峰分布,依此类推。模拟MODE()以返回所有模式可以通过几种方式实现。这里有一种方式(在捆绑的代码中,你可以看到另一种):
ctx.select(SALE.FISCAL_MONTH)
.from(SALE)
.groupBy(SALE.FISCAL_MONTH)
.having(count().ge(all(select(count())
.from(SALE)
.groupBy(SALE.FISCAL_MONTH))))
.fetch();
但是,当X的值为'foo'时有 1,000 个案例,而当值为'buzz'时有 999 个案例时,MODE()是'foo'。通过添加两个'buzz'的实例,MODE()切换到'buzz'。也许允许值有一些百分比的变化是个好主意。换句话说,使用总出现次数的百分比来模拟MODE()可以这样进行(这里为 75%):
ctx.select(avg(ORDERDETAIL.QUANTITY_ORDERED))
.from(ORDERDETAIL)
.groupBy(ORDERDETAIL.QUANTITY_ORDERED)
.having(count().ge(all(select(count().mul(0.75))
.from(ORDERDETAIL)
.groupBy(ORDERDETAIL.QUANTITY_ORDERED))))
.fetch();
你可以在OrderedSetAggregateFunctions捆绑代码中练习这些示例。
LISTAGG()
本节最后讨论的有序集聚合函数是LISTAGG()。此函数用于将给定值列表聚合为一个通过分隔符分隔的字符串(例如,对于生成 CSV 文件很有用)。SQL 标准要求存在分隔符和WITHIN GROUP子句。尽管如此,一些数据库将这些标准视为可选的,并应用某些默认值或在省略WITHIN GROUP子句时表现出未定义的行为。jOOQ 提供了listAgg(Field<?> field),它没有显式分隔符,以及listAgg(Field<?> field, String separator)。WITHIN GROUP子句不能省略。jOOQ 为不支持此功能的方言(如 MySQL(通过GROUP_CONCAT()模拟,因此逗号是默认分隔符)、PostgreSQL(通过STRING_AGG()模拟,因此没有默认分隔符)和 SQL Server(与 PostgreSQL 相同))提供了此函数,通过专有语法提供类似的功能。Oracle 支持LISTAGG(),没有默认分隔符。
这里有两个简单的示例,一个带有显式分隔符,一个没有,它们按薪资升序生成员工姓名列表作为Result<Record1<String>>:
ctx.select(listAgg(EMPLOYEE.FIRST_NAME)
.withinGroupOrderBy(EMPLOYEE.SALARY).as("listagg"))
.from(EMPLOYEE).fetch();
ctx.select(listAgg(EMPLOYEE.FIRST_NAME, ";")
.withinGroupOrderBy(EMPLOYEE.SALARY).as("listagg"))
.from(EMPLOYEE).fetch();
直接获取,可以通过fetchOneInto(String.class)获取String。
LISTAGG()可以与GROUP BY和ORDER BY结合使用,如下面的示例所示,它获取按职位标题的员工列表:
ctx.select(EMPLOYEE.JOB_TITLE,
listAgg(EMPLOYEE.FIRST_NAME, ",")
.withinGroupOrderBy(EMPLOYEE.FIRST_NAME).as("employees"))
.from(EMPLOYEE)
.groupBy(EMPLOYEE.JOB_TITLE)
.orderBy(EMPLOYEE.JOB_TITLE).fetch();
此外,LISTAGG()还支持一个窗口函数变体,如下所示:
ctx.select(EMPLOYEE.JOB_TITLE, listAgg(EMPLOYEE.SALARY, ",")
.withinGroupOrderBy(EMPLOYEE.SALARY)
.over().partitionBy(EMPLOYEE.JOB_TITLE))
.from(EMPLOYEE).fetch();
下面是一个来自卢卡斯·埃德(Lukas Eder)的有趣事实:“LISTAGG()不是一个真正的有序集聚合函数。它应该使用与ARRAY_AGG相同的ORDER BY语法。”参见这里的讨论:twitter.com/lukaseder/status/1237662156553883648。
你可以在OrderedSetAggregateFunctions中练习这些示例和其他示例。
分组、过滤、唯一性和函数
在本节中,分组指的是使用带有函数的GROUP BY,过滤指的是使用带有函数的FILTER子句,而唯一性指的是对唯一值上的聚合函数。
分组
如您所知,GROUP BY 是一个 SQL 子句,用于通过一个(或多个)作为参数给出的列对行进行分组。落在组中的行在给定列/表达式中具有匹配的值。典型的用例是在 GROUP BY 产生的数据组上应用聚合函数。
重要提示
尤其是在处理多个方言时,正确做法是在 GROUP BY 子句中列出 SELECT 子句中的所有非聚合列。这样,您可以避免在不同方言中可能的不确定/随机行为和错误(其中一些不会要求您这样做,例如 MySQL),而另一些则会(例如 Oracle)。
jOOQ 在所有方言中都支持 GROUP BY,因此以下是一个获取员工少于三个的办公室(OFFICE)的例子:
ctx.select(OFFICE.OFFICE_CODE, OFFICE.CITY,
nvl(groupConcat(EMPLOYEE.FIRST_NAME), "N/A").as("name"))
.from(OFFICE)
.leftJoin(EMPLOYEE)
.on(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE))
.groupBy(OFFICE.OFFICE_CODE, OFFICE.CITY)
.having(count().lt(3)).fetch();
这里是另一个例子,它计算每位员工每年的销售额总和,然后计算这些总和的平均值:
ctx.select(field(name("t", "en")),
avg(field(name("t", "ss"), Double.class))
.as("sale_avg"))
.from(ctx.select(SALE.EMPLOYEE_NUMBER,
SALE.FISCAL_YEAR, sum(SALE.SALE_))
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER, SALE.FISCAL_YEAR)
.asTable("t", "en", "fy", "ss"))
.groupBy(field(name("t", "en"))).fetch();
您可以在 GroupByDistinctFilter 中找到更多使用 GROUP BY 的例子。
过滤
如果我们想要通过针对列中有限值的集合应用聚合来细化查询,那么我们可以使用 CASE 表达式,如下例所示,它计算销售代表和其他员工的工资总和:
ctx.select(EMPLOYEE.SALARY,
(sum(case_().when(EMPLOYEE.JOB_TITLE.eq("Sales Rep"), 1)
.else_(0))).as("Sales Rep"),
(sum(case_().when(EMPLOYEE.JOB_TITLE.ne("Sales Rep"), 1)
.else_(0))).as("Others"))
.from(EMPLOYEE).groupBy(EMPLOYEE.SALARY).fetch();
如您所见,CASE 很灵活,但有点繁琐。一个更直接的方法是通过 FILTER 子句,这是 jOOQ 通过 filterWhere() 方法公开的,并且为不支持它的每个方言模拟(通常通过 CASE 表达式)。之前的查询可以通过 FILTER 表达如下:
ctx.select(EMPLOYEE.SALARY,
(count().filterWhere(EMPLOYEE.JOB_TITLE
.eq("Sales Rep"))).as("Sales Rep"),
(count().filterWhere(EMPLOYEE.JOB_TITLE
.ne("Sales Rep"))).as("Others"))
.from(EMPLOYEE)
.groupBy(EMPLOYEE.SALARY).fetch();
或者,以下是一个移除 ARRAY_AGG() 中的 NULL 值的例子:
ctx.select(arrayAgg(DEPARTMENT.ACCOUNTS_RECEIVABLE)
.filterWhere(DEPARTMENT.ACCOUNTS_RECEIVABLE.isNotNull()))
.from(DEPARTMENT).fetch();
FILTER 的另一个用途与将行转换为列的转置相关。例如,查看以下查询,它生成了每月和每年的销售额:
ctx.select(SALE.FISCAL_YEAR, SALE.FISCAL_MONTH,
sum(SALE.SALE_))
.from(SALE)
.groupBy(SALE.FISCAL_YEAR, SALE.FISCAL_MONTH).fetch();
查询返回了正确的结果,但形式出人意料。其垂直形式每行一个值,对于用户来说不太易读。很可能是用户更熟悉每年一行且每月有专用列的形式。因此,将一年的行转换为列应该能解决问题,这可以通过多种方式实现,包括 FILTER 子句,如下所示:
ctx.select(SALE.FISCAL_YEAR,
sum(SALE.SALE_).filterWhere(SALE.FISCAL_MONTH.eq(1))
.as("Jan_sales"),
sum(SALE.SALE_).filterWhere(SALE.FISCAL_MONTH.eq(2))
.as("Feb_sales"),
...
sum(SALE.SALE_).filterWhere(SALE.FISCAL_MONTH.eq(12))
.as("Dec_sales"))
.from(SALE).groupBy(SALE.FISCAL_YEAR).fetch();
FILTER 子句也可以与用作窗口函数的聚合函数一起考虑。在这种情况下,filterWhere() 位于聚合函数和 OVER() 子句之间。例如,以下查询仅对没有获得佣金员工的工资进行求和:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
EMPLOYEE.SALARY, OFFICE.OFFICE_CODE, OFFICE.CITY,
OFFICE.COUNTRY, sum(EMPLOYEE.SALARY)
.filterWhere(EMPLOYEE.COMMISSION.isNull())
.over().partitionBy(OFFICE.OFFICE_CODE))
.from(EMPLOYEE)
.join(OFFICE)
.on(EMPLOYEE.OFFICE_CODE.eq(OFFICE.OFFICE_CODE)).fetch();
此外,FILTER 子句还可以与有序集聚合函数一起使用。这样,我们可以在聚合之前移除未通过过滤器的行。以下是一个过滤工资高于 $80,000 的员工的例子,并通过 LISTAGG() 收集结果:
ctx.select(listAgg(EMPLOYEE.FIRST_NAME)
.withinGroupOrderBy(EMPLOYEE.SALARY)
.filterWhere(EMPLOYEE.SALARY.gt(80000)).as("listagg"))
.from(EMPLOYEE).fetch();
由于你在这里,我确信你会喜欢 Lukas Eder 关于在单个查询中计算多个聚合函数的文章:blog.jooq.org/2017/04/20/how-to-calculate-multiple-aggregate-functions-in-a-single-query/。
你可以在捆绑的GroupByDistinctFilter代码中练习这些示例和更多内容。
独特性
大多数聚合函数都有一个变体,用于将它们应用于一组不同的值。虽然你可以在 jOOQ 文档中找到所有这些函数,但让我们在这里快速列出countDistinct()、sumDistinct()、avgDistinct()、productDistinct()、groupConcatDistinct()、arrayAggDistinct()和collectDistinct()。为了完整性,我们还有minDistinct()和maxDistinct()。当一个函数不支持 jOOQ 时,我们仍然可以通过通用的aggregateDistinct()函数来调用它。
这里有一个使用countDistinct()来检索至少在 3 个不同年份有销售额的员工的示例:
ctx.select(SALE.EMPLOYEE_NUMBER)
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER)
.having(countDistinct(SALE.FISCAL_YEAR).gt(3)).fetch();
更多示例可以在捆绑的GroupByDistinctFilter代码中找到。
分组集
对于那些不熟悉分组集的人来说,让我们简要地跟随一个场景,旨在快速介绍并涵盖这个概念。考虑以下屏幕截图:
![Figure 13.24 – 使用分组集的每个查询]
![img/B16833_Figure_13.24.jpg]
图 13.24 – 使用分组集的每个查询
左侧的groupBy(SALE.EMPLOYEE_NUMBER)构造(分别,右侧的groupBy(SALE.FISCAL_YEAR))被称为分组集。分组集可以包含零个(空分组集)、一个或多个列。在我们的例子中,两个分组集都包含一个列。
通过UNION ALL运算符获取这两个包含两个分组集聚合数据的统一结果集,如图所示:
![Figure 13.25 – Union grouping sets]
![img/B16833_Figure_13.25.jpg]
图 13.25 – Union grouping sets
但是,正如你所看到的,即使对于只有两个分组集,这个查询也相当长。此外,它需要在将结果合并到单个结果集之前解决两个SELECT语句。这就是GROUP BY的GROUPING SETS(column_list)子句登场的地方。这个子句代表了一系列UNION查询的便捷简写,并且可以在以下示例中用来重写之前的查询:
ctx.select(SALE.EMPLOYEE_NUMBER,
SALE.FISCAL_YEAR, sum(SALE.SALE_))
.from(SALE)
.groupBy(groupingSets(
SALE.EMPLOYEE_NUMBER, SALE.FISCAL_YEAR))
.fetch();
太酷了,对吧?!然而,有一个问题应该被考虑。GROUPING SETS()将为每个维度在子总计级别生成NULL值。换句话说,很难区分一个真实的NULL值(存在于原始数据中)和一个生成的NULL值。但这项工作是GROUPING()函数的责任,它对于原始数据中的NULL值返回 0,而对于表示子总计的生成NULL值返回 1。
例如,如果我们在一个groupBy(groupingSets(OFFICE.CITY, OFFICE.COUNTRY))子句中编写查询,那么我们需要区分生成的NULL值和OFFICE.CITY以及OFFICE.COUNTRY的NULL值。通过使用GROUPING()来形成CASE表达式的条件,我们可以实现这一点,如下所示:
ctx.select(
case_().when(grouping(OFFICE.CITY).eq(1), "{generated}")
.else_(OFFICE.CITY).as("city"),
case_().when(grouping(OFFICE.COUNTRY).eq(1), "{generated}")
.else_(OFFICE.COUNTRY).as("country"),
sum(OFFICE.INTERNAL_BUDGET))
.from(OFFICE)
.groupBy(groupingSets(OFFICE.CITY, OFFICE.COUNTRY))
.fetch();
在这个查询中,我们将每个生成的NULL值替换为文本{generated},而原始数据上的NULL值将被检索为NULL值。因此,我们现在对NULL值的来源有了清晰的了解,如图所示:

图 13.26 – 无分组(左侧)与分组(右侧)
很可能,{null}和{generated}对我们的客户来说不会很有吸引力,因此我们可以稍微调整这个查询,使其更加友好,将{null}替换为"未指定",将{generated}替换为"-",如下所示:
ctx.select(case_().when(grouping(OFFICE.CITY).eq(1), "-")
.else_(isnull(OFFICE.CITY, "Unspecified")).as("city"),
case_().when(grouping(OFFICE.COUNTRY).eq(1), "-")
.else_(isnull(OFFICE.COUNTRY, "Unspecified")).as("country"),
sum(OFFICE.INTERNAL_BUDGET))
.from(OFFICE)
.groupBy(groupingSets(OFFICE.CITY, OFFICE.COUNTRY))
.fetch();
在GROUPING SETS()旁边,我们有ROLLUP和CUBE。这两个GROUP BY子句的扩展是GROUPING SETS()的语法糖。
ROLLUP分组是一系列分组集。例如,GROUP BY ROLLUP (x, y, z)等价于GROUP BY GROUPING SETS ((x, y, z), (x, y), (x), ())。ROLLUP通常用于层次数据的聚合,例如按年份 > 季度 > 月份 > 周的销售,或按地区 > 州 > 国家 > 城市的内部预算,如下所示:
ctx.select(
case_().when(grouping(OFFICE.TERRITORY).eq(1), "{generated}")
.else_(OFFICE.TERRITORY).as("territory"),
case_().when(grouping(OFFICE.STATE).eq(1), "{generated}")
.else_(OFFICE.STATE).as("state"),
case_().when(grouping(OFFICE.COUNTRY).eq(1), "{generated}")
.else_(OFFICE.COUNTRY).as("country"),
case_().when(grouping(OFFICE.CITY).eq(1), "{generated}")
.else_(OFFICE.CITY).as("city"),
sum(OFFICE.INTERNAL_BUDGET))
.from(OFFICE)
.where(OFFICE.COUNTRY.eq("USA"))
.groupBy(rollup(OFFICE.TERRITORY, OFFICE.STATE,
OFFICE.COUNTRY, OFFICE.CITY)).fetch();
输出如下所示:

图 13.27 – ROLLUP 输出
与ROLLUP类似,CUBE分组也可以被视为一系列分组集。然而,CUBE计算立方分组表达式的所有排列以及总和。因此,对于n个元素,CUBE产生 2^n 个分组集。例如,GROUP BY CUBE (x, y, x)等价于GROUP BY GROUPING SETS ((x, y, z), (x, y), (x, z), (y, z), (x), (y), (z), ())。
让我们应用CUBE来计算按州、国家和城市计算办公室内部预算的总和。查询如下所示:
ctx.select(
case_().when(grouping(OFFICE.STATE).eq(1), "{generated}")
.else_(OFFICE.STATE).as("state"),
case_().when(grouping(OFFICE.COUNTRY).eq(1), "{generated}")
.else_(OFFICE.COUNTRY).as("country"),
case_().when(grouping(OFFICE.CITY).eq(1), "{generated}")
.else_(OFFICE.CITY).as("city"),
sum(OFFICE.INTERNAL_BUDGET))
.from(OFFICE)
.where(OFFICE.COUNTRY.eq("USA"))
.groupBy(cube(OFFICE.STATE, OFFICE.COUNTRY, OFFICE.CITY))
.fetch();
最后,让我们来谈谈GROUPING_ID()函数。这个函数计算通过连接应用于GROUP BY子句中所有列的GROUPING()函数返回的值的二进制值的十进制等价物。以下是通过 jOOQ 的groupingId()使用GROUPING_ID()的示例:
ctx.select(
case_().when(grouping(OFFICE.TERRITORY).eq(1), "{generated}")
.else_(OFFICE.TERRITORY).as("territory"),
...
case_().when(grouping(OFFICE.CITY).eq(1), "{generated}")
.else_(OFFICE.CITY).as("city"),
groupingId(OFFICE.TERRITORY, OFFICE.STATE, OFFICE.COUNTRY,
OFFICE.CITY).as("grouping_id"),
sum(OFFICE.INTERNAL_BUDGET))
.from(OFFICE)
.where(OFFICE.COUNTRY.eq("USA"))
.groupBy(rollup(OFFICE.TERRITORY, OFFICE.STATE,
OFFICE.COUNTRY, OFFICE.CITY))
.fetch();
下面的屏幕截图显示了示例输出:

图 13.28 – GROUPING_ID()输出
GROUPING_ID()也可以用于HAVING来创建条件,如下所示:
… .having(groupingId(OFFICE.TERRITORY,
OFFICE.STATE, OFFICE.COUNTRY, OFFICE.CITY).eq(3))…
完整的查询可以在GroupingRollupCube捆绑代码中找到。
摘要
与 SQL 函数一起工作真是太有趣了!它们真正提升了 SQL 世界,并允许我们在数据处理过程中解决许多问题。正如你在本章中看到的,jOOQ 提供了对 SQL 函数的全面支持,涵盖了常规函数和聚合函数,到强大的窗口函数,有序集聚合函数(WITHIN GROUP)等等。当我们谈论这个话题时,请允许我推荐以下文章作为一篇极佳的阅读材料:blog.jooq.org/how-to-find-the-closest-subset-sum-with-sql/. 在下一章中,我们将探讨虚拟表(vtables)。
第十四章:派生表、CTE 和视图
派生表、CTE和视图是 SQL 环境中的重要角色。它们有助于组织和优化长而复杂的查询的重用——通常是基查询和/或昂贵的查询(在性能方面),并通过将代码分解成单独的步骤来提高可读性。主要来说,它们将某个查询与一个名称链接起来,该名称可能存储在模式中。换句话说,它们持有查询文本,当需要时可以通过关联的名称引用和执行这些文本。如果结果具体化,那么数据库引擎可以重用这些缓存的查询结果,否则,每次调用时都必须重新计算。
派生表、CTE 和视图具有特定的特性(包括数据库供应商特定的选项),选择它们取决于用例、涉及的数据和查询、数据库供应商和优化器等。像往常一样,我们从 jOOQ 的角度处理这个话题,因此我们的议程包括以下内容:
-
派生表
-
CTE
-
视图
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter14。
派生表
你是否曾经使用过嵌套的 SELECT(在表表达式中的 SELECT)?当然,你已经使用了!那么,你已经使用了所谓的派生表,其作用域是创建它的语句。大致来说,派生表应该像基表一样处理。换句话说,建议通过 AS 操作符给它及其列赋予有意义的名称。这样,你可以无歧义地引用派生表,并且你会尊重这样一个事实:大多数数据库不支持未命名的(未别名的)派生表。
jOOQ 允许我们通过asTable()或其同义词table()将派生表中的任何SELECT转换为表。让我们从一个简单的SELECT开始:
select(inline(1).as("one"));
这不是一个派生表,但它可以按照以下方式成为派生表(这两个是同义词):
Table<?> t = select(inline(1).as("one")).asTable();
Table<?> t = table(select(inline(1).as("one")));
在 jOOQ 中,我们可以通过局部变量t进一步引用这个派生表。将t声明为Table<?>或简单地使用var是很方便的。但当然,你也可以显式指定数据类型。这里,Table<Record1<Integer>>。
重要提示
org.jooq.Table类型可以引用派生表。
现在,结果t是一个未命名的派生表,因为它没有与它关联的显式别名。让我们看看当我们从t中选择某些内容时会发生什么:
ctx.selectFrom(t).fetch();
jOOQ 生成了以下 SQL(我们任意选择了 PostgreSQL 方言):
SELECT "alias_30260683"."one"
FROM (SELECT 1 AS "one") AS "alias_30260683"
jOOQ 检测到派生表缺少别名,因此它代表我们生成了一个别名(alias_30260683)。
重要提示
我们之前提到,大多数数据库供应商要求每个派生表都有一个显式别名。但是,正如你所看到的,jOOQ 允许我们省略这样的别名,并且当我们这样做时,jOOQ 会代表我们生成一个以确保生成的 SQL 在语法上是正确的。生成的别名是一个以alias_后缀的随机数字。这个别名不应被显式引用。jOOQ 将内部使用它来生成正确/有效的 SQL。
当然,如果我们明确指定了一个别名,那么 jOOQ 将会使用它:
Table<?> t = select(inline(1).as("one")).asTable("t");
Table<?> t = table(select(inline(1).as("one"))).as("t");
对应 PostgreSQL 的 SQL 如下所示:
SELECT "t"."one" FROM (SELECT 1 AS "one") AS "t"
这里是使用values()构造函数的另一个示例:
Table<?> t = values(row(1, "John"), row(2, "Mary"),
row(3, "Kelly"))
.as("t", "id", "name"); // or, .asTable("t", "id", "name");
通常,当我们明确引用它时,我们会明确指定一个别名,但每次都这样做也没有什么不妥。例如,jOOQ 对于以下内联派生表不需要显式别名,但添加它也没有什么问题:
ctx.select()
.from(EMPLOYEE)
.crossApply(select(count().as("sales_count")).from(SALE)
.where(SALE.EMPLOYEE_NUMBER
.eq(EMPLOYEE.EMPLOYEE_NUMBER)).asTable("t"))
.fetch();
jOOQ 依赖于t别名而不是生成一个。
在局部变量中提取/声明派生表
jOOQ 允许我们在使用它的语句之外提取/声明派生表,在这种情况下,它的存在和角色比在表表达式中嵌套它时更加清晰。
如果我们需要在多个语句中引用派生表,或者需要将其作为动态查询的一部分,或者我们只是想减轻复杂查询的负担,那么在局部变量中提取/声明派生表可能很有用。
例如,考虑以下查询:
ctx.select().from(
select(ORDERDETAIL.PRODUCT_ID, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50)))
.innerJoin(PRODUCT)
.on(field(name("price_each")).eq(PRODUCT.BUY_PRICE))
.fetch();
突出的子查询代表一个内联派生表。jOOQ 会自动为其关联一个别名,并使用该别名在外层SELECT中引用product_id和price_each列。当然,我们也可以提供显式别名,但这不是必需的:
ctx.select().from(
select(ORDERDETAIL.PRODUCT_ID, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50))
.asTable("t"))
.innerJoin(PRODUCT)
.on(field(name("t", "price_each")).eq(PRODUCT.BUY_PRICE))
.fetch();
这次,jOOQ 依赖于t别名而不是生成一个。接下来,让我们将这个子查询添加到另一个查询中,如下所示:
ctx.select(PRODUCT.PRODUCT_LINE,
PRODUCT.PRODUCT_NAME, field(name("price_each")))
.from(select(ORDERDETAIL.PRODUCT_ID,
ORDERDETAIL.PRICE_EACH).from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50)))
.innerJoin(PRODUCT)
.on(field(name("product_id")).eq(PRODUCT.PRODUCT_ID))
.fetch();
这个查询在编译时失败,因为on(field(name("product_id")).eq(PRODUCT.PRODUCT_ID))中对product_id列的引用是模糊的。jOOQ 会自动将生成的别名关联到内联派生表,但它无法确定product_id列来自派生表还是来自PRODUCT表。可以通过显式添加和使用派生表的别名来解决这个问题:
ctx.select(PRODUCT.PRODUCT_LINE, PRODUCT.PRODUCT_NAME,
field(name("t", "price_each")))
.from(select(ORDERDETAIL.PRODUCT_ID,
ORDERDETAIL.PRICE_EACH).from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50))
.asTable("t"))
.innerJoin(PRODUCT)
.on(field(name("t", "product_id"))
.eq(PRODUCT.PRODUCT_ID))
.fetch();
现在,jOOQ 依赖于t别名,歧义问题已经解决。或者,我们可以仅将唯一的别名ORDERDETAIL.PRODUCT_ID字段显式关联为select(ORDERDETAIL.PRODUCT_ID.as("pid")…,并通过这个别名引用它作为field(name("pid"))…。
到目前为止,我们有两个具有相同内联派生表的查询。我们可以通过在两个语句中使用它之前在 Java 局部变量中提取这个派生表来避免代码重复。换句话说,我们在 Java 局部变量中声明派生表,并通过这个局部变量在语句中引用它:
Table<?> t = select(
ORDERDETAIL.PRODUCT_ID, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50)).asTable("t");
因此,t 是我们的派生表。运行这段代码没有影响,也不会产生任何 SQL。jOOQ 只有在我们引用 t 时才会评估 t,但为了评估,t 必须在使用它的查询之前声明。这仅仅是 Java;我们只能使用已经声明的变量。当一个查询使用 t(例如,通过 t.field())时,jOOQ 会评估 t 并生成正确的 SQL。
例如,我们可以使用 t 重新编写我们的查询,如下所示:
ctx.select()
.from(t)
.innerJoin(PRODUCT)
.on(t.field(name("price_each"), BigDecimal.class)
.eq(PRODUCT.BUY_PRICE))
.fetch();
ctx.select(PRODUCT.PRODUCT_LINE,
PRODUCT.PRODUCT_NAME, t.field(name("price_each")))
.from(t)
.innerJoin(PRODUCT)
.on(t.field(name("product_id"), Long.class)
.eq(PRODUCT.PRODUCT_ID))
.fetch();
但是,为什么这次我们需要在 on(t.field(name("price_each"), BigDecimal.class) 和 .on(t.field(name("product_id"), Long.class) 中显式指定类型?答案是,字段不能从 t 中以类型安全的方式解引用。因此,我们必须指定适当的数据类型。这是一个纯 Java 问题,与 SQL 无关!
但是,有一个技巧可以帮助我们保持类型安全并减少冗余,这个技巧就是使用 <T> Field<T> field(Field<T> field) 方法。对这个方法的最佳解释来自 jOOQ 文档本身。以下图是 jOOQ 官方文档的截图:

图 14.1 –
表达式 t.field(name("price_each"), …) 间接引用了字段 ORDERDETAIL.PRICE_EACH,而 t.field(name("product_id"), …) 间接引用了字段 ORDERDETAIL.PRODUCT_ID。因此,根据前面的图示,我们可以以类型安全的方式重新编写我们的查询,如下所示:
ctx.select(PRODUCT.PRODUCT_LINE, PRODUCT.PRODUCT_NAME,
t.field(ORDERDETAIL.PRICE_EACH))
.from(t)
.innerJoin(PRODUCT)
.on(t.field(ORDERDETAIL.PRODUCT_ID)
.eq(PRODUCT.PRODUCT_ID))
.fetch();
ctx.select()
.from(t)
.innerJoin(PRODUCT)
.on(t.field(ORDERDETAIL.PRICE_EACH)
.eq(PRODUCT.BUY_PRICE))
.fetch();
太棒了!现在,我们可以以“类型安全”的方式重用 t!然而,请注意,
这里是另一个例子,它使用了在派生表中提取的两个 Field 以及查询本身:
// fields
Field<BigDecimal> avg = avg(ORDERDETAIL.PRICE_EACH).as("avg");
Field<Long> ord = ORDERDETAIL.ORDER_ID.as("ord");
// derived table
Table<?> t = select(avg, ord).from(ORDERDETAIL)
.groupBy(ORDERDETAIL.ORDER_ID).asTable("t");
// query
ctx.select(ORDERDETAIL.ORDER_ID, ORDERDETAIL
.ORDERDETAIL_ID,ORDERDETAIL.PRODUCT_ID,
ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL, t)
.where(ORDERDETAIL.ORDER_ID.eq(ord)
.and(ORDERDETAIL.PRICE_EACH.lt(avg)))
.orderBy(ORDERDETAIL.ORDER_ID)
.fetch();
在这里,ord 和 avg 被表示为非限定形式(没有使用派生表别名作为前缀)。但是,由于 <T> Field<T> field(Field<T> field) 方法,我们可以获得限定版本:
...where(ORDERDETAIL.ORDER_ID.eq(t.field(ord))
.and(ORDERDETAIL.PRICE_EACH.lt(t.field(avg))))
...
接下来,让我们看看一个使用 fields() 和 asterisk() 来引用在局部变量中提取的派生表所有列的示例:
Table<?> t = ctx.select(SALE.EMPLOYEE_NUMBER,
count(SALE.SALE_).as("sales_count"))
.from(SALE).groupBy(SALE.EMPLOYEE_NUMBER).asTable("t");
ctx.select(t.fields()).from(t)
.orderBy(t.field(name("sales_count"))).fetch();
ctx.select(t.asterisk(),
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.from(EMPLOYEE, t)
.where(EMPLOYEE.EMPLOYEE_NUMBER.eq(
t.field(name("employee_number"), Long.class)))
.orderBy(t.field(name("sales_count"))).fetch();
注意,提取子查询并不是将其转换为 Table 的强制要求。有些情况下,将其提取为简单的 SELECT 就足够了。例如,当子查询不是一个派生表时,我们可以这样做:
ctx.selectFrom(PRODUCT)
.where(row(PRODUCT.PRODUCT_ID, PRODUCT.BUY_PRICE).in(
select(ORDERDETAIL.PRODUCT_ID, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50))))
.fetch();
这个子查询(它不是一个派生表)可以局部提取并像这样使用:
// SelectConditionStep<Record2<Long, BigDecimal>>
var s = select(
ORDERDETAIL.PRODUCT_ID, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50));
ctx.selectFrom(PRODUCT)
.where(row(PRODUCT.PRODUCT_ID, PRODUCT.BUY_PRICE).in(s))
.fetch();
不需要别名(jOOQ 知道这不是一个派生表,不需要别名,因此它不会生成一个)也不需要将其转换为 Table。实际上,jOOQ 非常灵活,甚至允许我们这样做:
var t = select(ORDERDETAIL.PRODUCT_ID, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50));
ctx.select(PRODUCT.PRODUCT_LINE, PRODUCT.PRODUCT_NAME,
t.field(ORDERDETAIL.PRICE_EACH))
.from(t)
.innerJoin(PRODUCT)
.on(t.field(ORDERDETAIL.PRODUCT_ID)
.eq(PRODUCT.PRODUCT_ID))
.fetch();
ctx.select()
.from(t)
.innerJoin(PRODUCT)
.on(t.field(ORDERDETAIL.PRICE_EACH)
.eq(PRODUCT.BUY_PRICE))
.fetch();
请放心,jOOQ 不会要求您将 t 转换为 Table。jOOQ 推断这是一个派生表,并按照预期在渲染的 SQL 中关联和引用一个生成的别名。因此,只要您不想将显式别名关联到派生表,并且 jOOQ 在您的查询中不特别要求 Table 实例,就没有必要将提取的 SELECT 转换为 Table 实例。当您需要一个 Table 实例但不需要为其指定别名时,只需使用不带参数的 asTable() 方法:
Table<?> t = select(
ORDERDETAIL.PRODUCT_ID, ORDERDETAIL.PRICE_EACH)
.from(ORDERDETAIL)
.where(ORDERDETAIL.QUANTITY_ORDERED.gt(50)).asTable();
您可以查看这些示例以及其他在 DerivedTable 中的示例。
探索 jOOQ 中的常见表表达式 (CTEs)
CTEs 通过 SQL-99 的 WITH 子句来表示。您已经在之前的章节中看到了几个 CTE 的例子,例如,在 第十三章 利用 SQL 函数 中,您看到了一个用于计算 z 分数的 CTE。
大致来说,通过 CTEs,我们将本应作为派生表重复的代码提取出来。通常,一个 CTE 包含一系列派生表,这些表按照一定顺序放置在 SELECT 语句之前。顺序很重要,因为这些派生表是按照这个顺序创建的,并且一个 CTE 元素只能引用之前的 CTE 元素。
基本上,我们区分常规(非递归)CTEs 和递归 CTEs。
常规 CTEs
常规 CTE 将名称与具有类似于 SELECT、INSERT、UPDATE、DELETE 或 MERGE(DML 语句的 CTE 是非常有用的特定供应商扩展)语句作用域的临时结果集相关联。但是,派生表或其他类型的子查询也可以有自己的 CTE,例如 SELECT x.a FROM (WITH t (a) AS (SELECT 1) SELECT a FROM t) x。
CTE 的基本语法如下(有关特定数据库供应商的确切语法,请参阅文档):
WITH CTE_name [(column_name [, ...])]
AS
(CTE_definition) [, ...]
SQL_statement_using_CTE;
在 jOOQ 中,CTE 由 org.jooq.CommonTableExpression 类表示,并扩展了常用的 org.jooq.Table,因此 CTE 可以在任何可以使用 Table 的地方使用。CTE_name 表示在查询中稍后用于引用 CTE 的名称,在 jOOQ 中,可以作为 name() 或 with() 方法的参数指定。
column_name 标记了 CTE_name 后跟的逗号分隔的列列表的位置。这里指定的列数必须与 CTE_definition 中定义的列数相等。在 jOOQ 中,当使用 name() 方法为 CTE_name 时,此列表可以通过 fields() 方法指定。否则,它可以作为 with() 参数的一部分在 CTE_name 之后指定。
AS 关键字通过 as(Select<?> select) 方法在 jOOQ 中表示。因此,as() 的参数是 CTE_definition。从 jOOQ 3.15 版本开始,as(ResultQuery<?>) 方法接受一个 ResultQuery<?>,允许在 PostgreSQL 中将 INSERT ... RETURNING 和其他 DML ... RETURNING 语句作为 CTE 使用。
最后,我们有使用 CTE 并通过CTE_name引用它的 SQL。
例如,以下名为cte_sales的 CTE 计算每个员工的销售额总和:
CommonTableExpression<Record2<Long, BigDecimal>> t
= name("cte_sales").fields("employee_nr", "sales")
.as(select(SALE.EMPLOYEE_NUMBER, sum(SALE.SALE_))
.from(SALE).groupBy(SALE.EMPLOYEE_NUMBER));
这是可以通过 jOOQ 中的局部变量t引用的 CTE 声明。现在运行此代码片段不会执行SELECT也不会产生任何效果。一旦我们在 SQL 查询中使用t,jOOQ 将评估它以渲染预期的 CTE。该 CTE 将由数据库执行。
正如在局部变量中声明派生表的情况下一样,在 CTE 的情况下,字段不能以类型安全的方式从t中解引用,因此我们需要在使用 CTE 的查询中指定适当的数据类型。再次强调,这是一个纯 Java 问题,与 SQL 无关!
Lukas Eder 分享了以下内容:关于在解引用 CTE 或派生表字段时缺乏类型安全:这通常是重新编写 SQL 语句的机会,使其不再使用 CTE。在 Stack Overflow 上,我看到了许多案例,其中人们试图将所有内容放入多层令人困惑的 CTE 中,而实际分解的查询可能要简单得多(例如,如果他们知道窗口函数,或正确的操作逻辑顺序等)。仅仅因为你可以使用 CTE,并不意味着你必须在每个地方*都使用它们。
因此,这里是一个使用我们的 CTE(公用表表达式),t,来获取销售额最高的员工的例子:
ctx.with(t)
.select() // or, .select(t.field("employee_nr"),
// t.field("sales"))
.from(t)
.where(t.field("sales", Double.class)
.eq(select(max(t.field("sales" ,Double.class)))
.from(t))).fetch();
通过将 CTE 字段提取为局部变量,我们可以将我们的 CTE 声明重写如下:
Field<Long> e = SALE.EMPLOYEE_NUMBER;
Field<BigDecimal> s = sum(SALE.SALE_);
CommonTableExpression<Record2<Long, BigDecimal>> t
= name("cte_sales").fields(e.getName(), s.getName())
.as(select(e, s).from(SALE).groupBy(e));
使用此 CTE 的 SQL 如下:
ctx.with(t)
.select() // or, .select(t.field(e.getName()),
// t.field(s.getName()))
.from(t)
.where(t.field(s.getName(), s.getType())
.eq(select(max(t.field(s.getName(), s.getType())))
.from(t))).fetch();
当然,依靠上一节中引入的<T> Field<T> field,我们可以编写如下类型安全的 CTE:
ctx.with(t)
.select() // or, .select(t.field(e), t.field(s))
.from(t)
.where(t.field(s)
.eq(select(max(t.field(s))).from(t))).fetch();
作为之前显式 CTE 的替代,我们可以编写如下内联 CTE:
ctx.with("cte_sales", "employee_nr", "sales")
.as(select(SALE.EMPLOYEE_NUMBER, sum(SALE.SALE_))
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER))
.select() // or, field(name("employee_nr")),
// field(name("sales"))
.from(name("cte_sales"))
.where(field(name("sales"))
.eq(select(max(field(name("sales"))))
.from(name("cte_sales")))).fetch();
通过任意选择 PostgreSQL 方言,我们为所有之前的 CTE 渲染了以下 SQL:
WITH "cte_sales"("employee_nr", "sales") AS
(SELECT "public"."sale"."employee_number",
sum("public"."sale"."sale")
FROM "public"."sale"
GROUP BY "public"."sale"."employee_number")
SELECT * FROM "cte_sales"
WHERE "sales" = (SELECT max("sales") FROM "cte_sales")
你可以在名为CteSimple的捆绑代码中检查这些示例。到目前为止,我们的 CTE 仅用于SELECT语句。但是,CTE 也可以用于 DML 语句,如INSERT、UPDATE、DELETE和MERGE。
作为 SELECT 和 DML 的 CTE
jOOQ 支持在INSERT、UPDATE、DELETE和MERGE中使用 CTE。例如,以下代码片段将随机部分从SALE表插入到一个全新的表中:
ctx.createTableIfNotExists("sale_training").as(
selectFrom(SALE)).withNoData().execute();
ctx.with("training_sale_ids", "sale_id")
.as(select(SALE.SALE_ID).from(SALE)
.orderBy(rand()).limit(10))
.insertInto(table(name("sale_training")))
.select(select().from(SALE).where(SALE.SALE_ID.notIn(
select(field(name("sale_id"), Long.class))
.from(name("training_sale_ids")))))
.execute();
这里还有一个例子,它通过在UPDATE中使用 CTE 将产品的价格(PRODUCT.BUY_PRICE)更新为最大订单价格(max(ORDERDETAIL.PRICE_EACH)):
ctx.with("product_cte", "product_id", "max_buy_price")
.as(select(ORDERDETAIL.PRODUCT_ID,
max(ORDERDETAIL.PRICE_EACH))
.from(ORDERDETAIL)
.groupBy(ORDERDETAIL.PRODUCT_ID))
.update(PRODUCT)
.set(PRODUCT.BUY_PRICE, coalesce(field(
select(field(name("max_buy_price"), BigDecimal.class))
.from(name("product_cte"))
.where(PRODUCT.PRODUCT_ID.eq(
field(name("product_id"), Long.class)))),
PRODUCT.BUY_PRICE)).execute();
你可以练习这些示例,包括在CteSelectDml中使用 CTE 在DELETE和MERGE,以及其他示例。接下来,让我们看看如何在 PostgreSQL 中将 CTE 作为 DML 来表示。
作为 DML 的 CTE
从 jOOQ 3.15 版本开始,CTE as(ResultQuery<?>) 方法接受一个 ResultQuery<?>,以便在 PostgreSQL 中使用 INSERT ... RETURNING 和其他 DML … RETURNING 语句作为 CTE。以下是一个简单的 CTE,用于存储返回的 SALE_ID:
ctx.with("cte", "sale_id")
.as(insertInto(SALE, SALE.FISCAL_YEAR, SALE.SALE_,
SALE.EMPLOYEE_NUMBER, SALE.FISCAL_MONTH,
SALE.REVENUE_GROWTH)
.values(2005, 1250.55, 1504L, 1, 0.0)
.returningResult(SALE.SALE_ID))
.selectFrom(name("cte"))
.fetch();
让我们编写一个 CTE 来更新所有具有 null EMPLOYEE.COMMISSION的员工的 SALE.REVENUE_GROWTH。所有更新的 SALE.EMPLOYEE_NUMBER 都存储在 CTE 中,并进一步用于插入 EMPLOYEE_STATUS,如下所示:
ctx.with("cte", "employee_number")
.as(update(SALE).set(SALE.REVENUE_GROWTH, 0.0)
.where(SALE.EMPLOYEE_NUMBER.in(
select(EMPLOYEE.EMPLOYEE_NUMBER).from(EMPLOYEE)
.where(EMPLOYEE.COMMISSION.isNull())))
.returningResult(SALE.EMPLOYEE_NUMBER))
.insertInto(EMPLOYEE_STATUS, EMPLOYEE_STATUS
.EMPLOYEE_NUMBER,EMPLOYEE_STATUS.STATUS,
EMPLOYEE_STATUS.ACQUIRED_DATE)
.select(select(field(name("employee_number"), Long.class),
val("REGULAR"), val(LocalDate.now())).from(name("cte")))
.execute();
你可以在名为 CteDml 的捆绑代码中查看更多示例,用于 PostgreSQL。接下来,让我们看看如何在 CTE 中嵌入普通 SQL。
CTE 和普通 SQL
在 CTE 中使用普通 SQL 很简单,如下例所示:
CommonTableExpression<Record2<Long, String>>cte = name("cte")
.fields("pid", "ppl").as(resultQuery(
// Put any plain SQL statement here
"""
select "public"."product"."product_id",
"public"."product"."product_line"
from "public"."product"
where "public"."product"."quantity_in_stock" > 0
"""
).coerce(field("pid", BIGINT), field("ppl", VARCHAR)));
Result<Record2<Long, String>> result =
ctx.with(cte).selectFrom(cte).fetch();
你可以在名为 CtePlainSql 的捆绑代码中测试此示例。接下来,让我们解决一些常见的 CTE 类型,并继续使用两个或更多 CTE 的查询。
连接 CTE
有时,一个查询必须利用多个 CTE。例如,让我们考虑 PRODUCTLINE、PRODUCT 和 ORDERDETAIL 表。我们的目标是获取每个产品线的一些信息(例如,描述),产品总数和总销售额。为此,我们可以编写一个 CTE,将 PRODUCTLINE 与 PRODUCT 连接起来,并计算每个产品线的总产品数,另一个 CTE 将 PRODUCT 与 ORDERDETAIL 连接起来,并计算每个产品线的总销售额。然后,这两个 CTE 在 SELECT 中使用,以获取以下内联 CTE 中的最终结果:
ctx.with("cte_productline_counts")
.as(select(PRODUCT.PRODUCT_LINE, PRODUCT.CODE,
count(PRODUCT.PRODUCT_ID).as("product_count"),
PRODUCTLINE.TEXT_DESCRIPTION.as("description"))
.from(PRODUCTLINE).join(PRODUCT).onKey()
.groupBy(PRODUCT.PRODUCT_LINE, PRODUCT.CODE,
PRODUCTLINE.TEXT_DESCRIPTION))
.with("cte_productline_sales")
.as(select(PRODUCT.PRODUCT_LINE,
sum(ORDERDETAIL.QUANTITY_ORDERED
.mul(ORDERDETAIL.PRICE_EACH)).as("sales"))
.from(PRODUCT).join(ORDERDETAIL).onKey()
.groupBy(PRODUCT.PRODUCT_LINE))
.select(field(name("cte_productline_counts",
"product_line")), field(name("code")),
field(name("product_count")),
field(name("description")),
field(name("sales")))
.from(name("cte_productline_counts"))
.join(name("cte_productline_sales"))
.on(field(name("cte_productline_counts",
"product_line"))
.eq(field(name("cte_productline_sales",
"product_line"))))
.orderBy(field(name("cte_productline_counts",
"product_line")))
.fetch();
在捆绑的代码(CteSimple)中,你可以看到显式的 CTE 版本。
嵌套 CTE
CTE 也可以嵌套。例如,这里我们有一个“基础”CTE,计算每个办公室的员工平均工资。接下来的两个 CTE 分别从“基础”CTE 中获取最小和最大平均值。最后,我们的查询交叉连接这些 CTE:
ctx.with("avg_per_office")
.as(select(EMPLOYEE.OFFICE_CODE.as("office"),
avg(EMPLOYEE.SALARY).as("avg_salary_per_office"))
.from(EMPLOYEE)
.groupBy(EMPLOYEE.OFFICE_CODE))
.with("min_salary_office")
.as(select(min(field(name("avg_salary_per_office")))
.as("min_avg_salary_per_office"))
.from(name("avg_per_office")))
.with("max_salary_office")
.as(select(max(field(name("avg_salary_per_office")))
.as("max_avg_salary_per_office"))
.from(name("avg_per_office")))
.select()
.from(name("avg_per_office"))
.crossJoin(name("min_salary_office"))
.crossJoin(name("max_salary_office"))
.fetch();
潜在的输出显示在下图中:
![Figure 14.2 – 嵌套 CTE 示例的输出]
![img/B16833_Figure_14.2.jpg]
Figure 14.2 – 嵌套 CTE 示例的输出
在捆绑的代码(CteSimple)中,你可以看到显式的 CTE 版本。
一些数据库(例如,MySQL 和 PostgreSQL)允许你通过 FROM 子句嵌套 CTE。以下是一个示例:
ctx.with("t2")
.as(select(avg(field("sum_min_sal", Float.class))
.as("avg_sum_min_sal")).from(
with("t1")
.as(select(min(EMPLOYEE.SALARY).as("min_sal"))
.from(EMPLOYEE)
.groupBy(EMPLOYEE.OFFICE_CODE)).select(
sum(field("min_sal", Float.class))
.as("sum_min_sal"))
.from(name("t1"))
.groupBy(field("min_sal"))))
.select()
.from(name("t2"))
.fetch();
因此,这是一个三步查询:首先,我们计算每个办公室的最低工资;其次,我们计算每个最低工资的工资总和;最后,我们计算这些总和的平均值。
物化 CTE
你有一个昂贵的 CTE,它检索一个相对较小的结果集,并且被使用两次或更多次?那么,你很可能有一个你可能想要物化的 CTE。然后,物化 CTE 可以被父查询多次引用,而无需重新计算结果。
在 jOOQ 中,可以通过 asMaterialized() 方法物化一个 CTE。根据数据库的不同,jOOQ 将渲染适当的 SQL。例如,考虑以下物化 CTE:
ctx.with("cte", "customer_number",
"order_line_number", "sum_price", "sum_quantity")
.asMaterialized(
select(ORDER.CUSTOMER_NUMBER,
ORDERDETAIL.ORDER_LINE_NUMBER,
sum(ORDERDETAIL.PRICE_EACH),
sum(ORDERDETAIL.QUANTITY_ORDERED))
.from(ORDER)
.join(ORDERDETAIL)
.on(ORDER.ORDER_ID.eq(ORDERDETAIL.ORDER_ID))
.groupBy(ORDERDETAIL.ORDER_LINE_NUMBER,
ORDER.CUSTOMER_NUMBER))
.select(field(name("customer_number")),
inline("Order Line Number").as("metric"),
field(name("order_line_number"))).from(name("cte")) // 1
.unionAll(select(field(name("customer_number")),
inline("Sum Price").as("metric"),
field(name("sum_price"))).from(name("cte"))) // 2
.unionAll(select(field(name("customer_number")),
inline("Sum Quantity").as("metric"),
field(name("sum_quantity"))).from(name("cte"))) // 3
.fetch();
这个 CTE 应该被评估三次(在代码中用 //1、//2 和 //3 表示)。希望借助 asMaterialized(),CTE 的结果应该被物化并重用,而不是重新计算。
一些数据库会检测到一个 CTE 被多次使用(外层查询中多次引用 WITH 子句)并自动尝试将结果集物化为一个优化屏障。例如,即使我们没有使用 asMaterialized(),仅仅使用 as(),PostgreSQL 也会物化上述 CTE,因为 WITH 查询被调用了三次。
但是,PostgreSQL 允许我们控制 CTE 的物化并改变默认行为。如果我们想强制内联 CTE 而不是物化它,那么我们就在 CTE 中添加 NOT MATERIALIZED 提示。在 jOOQ 中,这是通过 asNotMaterialized() 实现的:
ctx.with("cte", "customer_number",
"order_line_number", "sum_price", "sum_quantity")
.asNotMaterialized(select(ORDER.CUSTOMER_NUMBER,
ORDERDETAIL.ORDER_LINE_NUMBER, ...
另一方面,在 Oracle 中,我们可以通过 /*+ materialize */ 和 /*+ inline */ 提示来控制物化。使用 jOOQ 的 asMaterialized() 生成 /*+ materialize */ 提示,而 asNotMaterialized() 生成 /*+ inline */ 提示。使用 jOOQ 的 as() 不会生成任何提示,因此 Oracle 的优化器可以自由地按照默认行为操作。
然而,Lukas Eder 说:请注意,Oracle 的提示信息并未文档化,因此它们可能会改变(尽管所有可能的 Oracle 大师博客都记录了它们的实际功能,因此了解 Oracle,它们不会轻易改变)。如果没有明确记录,任何 物化技巧 的 保证 都是不存在的。
其他数据库根本不支持物化,或者只将其用作优化器的内部机制(例如,MySQL)。使用 jOOQ 的 as()、asMaterialized() 和 asNotMaterialized() 在 MySQL 中生成相同的 SQL,因此我们无法依赖于显式的物化。在这种情况下,我们可以尝试重写我们的 CTE 以避免重复调用。例如,之前的 CTE 可以通过 LATERAL 优化,在 MySQL 中无需物化:
ctx.with("cte")
.as(
select(ORDER.CUSTOMER_NUMBER,
ORDERDETAIL.ORDER_LINE_NUMBER,
sum(ORDERDETAIL.PRICE_EACH).as("sum_price"),
sum(ORDERDETAIL.QUANTITY_ORDERED)
.as("sum_quantity"))
.from(ORDER)
.join(ORDERDETAIL)
.on(ORDER.ORDER_ID.eq(ORDERDETAIL.ORDER_ID))
.groupBy(ORDERDETAIL.ORDER_LINE_NUMBER,
ORDER.CUSTOMER_NUMBER))
.select(field(name("customer_number")),
field(name("t", "metric")), field(name("t", "value")))
.from(table(name("cte")), lateral(
select(inline("Order Line Number").as("metric"),
field(name("order_line_number")).as("value"))
.unionAll(select(inline("Sum Price").as("metric"),
field(name("sum_price")).as("value")))
.unionAll(select(inline("Sum Quantity").as("metric"),
field(name("sum_quantity")).as("value"))))
.as("t")).fetch();
这里是 SQL Server 的替代方案(类似于 MySQL,SQL Server 不公开任何对显式物化的支持;然而,有一个提案建议微软添加一个类似于 Oracle 的专用提示),使用 CROSS APPLY 和 VALUES 构造函数:
ctx.with("cte")
.as(select(ORDER.CUSTOMER_NUMBER,
ORDERDETAIL.ORDER_LINE_NUMBER,
sum(ORDERDETAIL.PRICE_EACH).as("sum_price"),
sum(ORDERDETAIL.QUANTITY_ORDERED)
.as("sum_quantity"))
.from(ORDER)
.join(ORDERDETAIL)
.on(ORDER.ORDER_ID.eq(ORDERDETAIL.ORDER_ID))
.groupBy(ORDERDETAIL.ORDER_LINE_NUMBER,
ORDER.CUSTOMER_NUMBER))
.select(field(name("customer_number")),
field(name("t", "metric")), field(name("t", "value")))
.from(name("cte")).crossApply(
values(row("Order Line Number",
field(name("cte", "order_line_number"))),
row("Sum Price", field(name("cte", "sum_price"))),
row("Sum Quantity", field(name("cte", "sum_quantity"))))
.as("t", "metric", "value")).fetch();
一个优秀的基于成本的优化器应该始终将所有 SQL 语句重写为最优执行计划,因此今天可能有效的方法,明天可能就不适用了——例如这个 LATERAL/CROSS APPLY 的小技巧。如果优化器足够聪明,能够检测到 LATERAL/CROSS APPLY 是不必要的(例如,由于缺乏相关性),那么它可能(应该是)被消除。
您可以在CteSimple中查看所有这些示例。此外,在CteAggRem应用程序中,您可以练习一个 CTE,用于计算前 N 项并汇总(求和)剩余项到单独的行中。基本上,虽然数据库中排名项以计算前 N 项或后 N 项是一个常见问题,但与此相关的一个常见要求是获取所有其他行(不包含在前 N 项或后 N 项中的行)到单独的行中。这在呈现数据时提供完整上下文很有帮助。
在CteWMAvg代码中,您可以查看一个以突出显示最近点为主要目标的统计问题。这个问题被称为加权移动平均(WMA)。这是移动平均家族的一部分(en.wikipedia.org/wiki/Moving_average),简而言之,WMA 是一种移动平均,其中滑动窗口中的先前值(点)具有不同的(分数)权重。
递归 CTE
除了常规 CTE 之外,我们还有递归 CTE。
简而言之,递归 CTE 重现了编程中 for 循环的概念。递归 CTE 可以通过引用自身来处理和探索层次数据。在递归 CTE 背后,有两个主要成员:
-
锚定成员 – 其目标是选择涉及递归步骤的起始行。
-
递归成员 – 其目标是生成 CTE 的行。第一次迭代步骤针对锚定行,而第二次迭代步骤针对递归步骤中先前创建的行。此成员出现在 CTE 定义部分的
UNION ALL之后。更准确地说,UNION ALL是某些方言所必需的,但其他方言也可以使用UNION进行递归,语义略有不同。
在 jOOQ 中,递归 CTE 可以通过withRecursive()方法表达。
这是一个简单的递归 CTE,用于计算著名的斐波那契数。锚定成员等于 1,递归成员应用斐波那契公式直到数字 20:
ctx.withRecursive("fibonacci", "n", "f", "f1")
.as(select(inline(1L), inline(0L), inline(1L))
.unionAll(select(field(name("n"), Long.class).plus(1),
field(name("f"), Long.class).plus(field(name("f1"))),
field(name("f"), Long.class))
.from(name("fibonacci"))
.where(field(name("n")).lt(20))))
.select(field(name("n")), field(name("f")).as("f_nbr"))
.from(name("fibonacci"))
.fetch();
嗯,这很简单,不是吗?接下来,让我们解决一个可以通过递归 CTE 解决的问题,即著名的旅行商问题。更多详细信息请参阅:en.wikipedia.org/wiki/Travelling_salesman_problem。简而言之,我们将这个问题解释为通过代表我们办公室位置的几个城市找到最短私人航班。基本上,在OFFICE_FLIGHTS中,我们有我们办公室之间的路线,作为OFFICE_FLIGHTS.DEPART_TOWN、OFFICE_FLIGHTS.ARRIVAL_TOWN和OFFICE_FLIGHTS.DISTANCE_KM。例如,我们的 CTE 将使用洛杉矶作为其锚定城市,然后递归遍历每个其他城市以到达东京:
String from = "Los Angeles";
String to = "Tokyo";
ctx.withRecursive("flights",
"arrival_town", "steps", "total_distance_km", "path")
.as(selectDistinct(OFFICE_FLIGHTS.DEPART_TOWN
.as("arrival_town"), inline(0).as("steps"), inline(0)
.as("total_distance_km"), cast(from, SQLDataType.VARCHAR)
.as("path"))
.from(OFFICE_FLIGHTS)
.where(OFFICE_FLIGHTS.DEPART_TOWN.eq(from))
.unionAll(select(field(name("arrivals", "arrival_town"),
String.class), field(name("flights", "steps"),
Integer.class).plus(1), field(name("flights",
"total_distance_km"), Integer.class).plus(
field(name("arrivals", "distance_km"))),
concat(field(name("flights", "path")),inline(","),
field(name("arrivals", "arrival_town"))))
.from(OFFICE_FLIGHTS.as("arrivals"),
table(name("flights")))
.where(field(name("flights", "arrival_town"))
.eq(field(name("arrivals", "depart_town")))
.and(field(name("flights", "path"))
.notLike(concat(inline("%"),
field(name("arrivals", "arrival_town")),
inline("%")))))))
.select()
.from(name("flights"))
.where(field(name("arrival_town")).eq(to))
.orderBy(field(name("total_distance_km")))
.fetch();
下一个图中显示了一些可能的输出:

图 14.3 – 从洛杉矶到东京的最短私人航班,18,983 公里
你可以在CteRecursive中练习这些示例。
CTE 和窗口函数
在本节中,让我们看看两个结合 CTE 和窗口函数的示例,让我们从一个计算 ID 间隔的示例开始。例如,每个EMPLOYEE都有一个相关的EMPLOYEE_NUMBER,我们想知道数据值中缺失了多少个值(缺失的EMPLOYEE_NUMBER),以及有多少现有值是连续的。这是ROW_NUMBER()窗口和以下 CTE 的工作:
ctx.with("t", "data_val", "data_seq", "absent_data_grp")
.as(select(EMPLOYEE.EMPLOYEE_NUMBER,
rowNumber().over()
.orderBy(EMPLOYEE.EMPLOYEE_NUMBER)))
EMPLOYEE.EMPLOYEE_NUMBER.minus(
rowNumber().over()
.orderBy(EMPLOYEE.EMPLOYEE_NUMBER)))
.from(EMPLOYEE))
.select(field(name("absent_data_grp")), count(),
min(field(name("data_val"))).as("start_data_val"))
.from(name("t"))
.groupBy(field(name("absent_data_grp")))
.orderBy(field(name("absent_data_grp")))
.fetch();
虽然你可以在捆绑的代码中看到这个示例,但让我们看看另一个通过订单值找到每个产品线百分位排名的示例:
ctx.with("t", "product_line", "sum_price_each")
.as(select(PRODUCT.PRODUCT_LINE,
sum(ORDERDETAIL.PRICE_EACH))
.from(PRODUCT)
.join(ORDERDETAIL)
.on(PRODUCT.PRODUCT_ID.eq(ORDERDETAIL.PRODUCT_ID))
.groupBy(PRODUCT.PRODUCT_LINE))
.select(field(name("product_line")),
field(name("sum_price_each")),
round(percentRank().over()
.orderBy(field(name("sum_price_each"))).mul(100), 2)
.concat("%").as("percentile_rank"))
.from(name("t"))
.fetch();
你可以在CteWf中找到这些示例,以及另一个每年找到价值最高的三个订单的示例。
使用 CTE 生成数据
CTE 在生成数据方面非常方便——它们作为使用 CTE 的 SQL 语句的数据源。例如,使用 CTE 和VALUES构造函数可以这样做:
ctx.with("dt")
.as(select()
.from(values(row(1, "John"), row(2, "Mary"), row(3, "Kelly"))
.as("t", "id", "name")))
.select()
.from(name("dt"))
.fetch();
或者,使用 CTE 来解包数组可以这样做:
ctx.with("dt")
.as(select().from(unnest(new String[]
{"John", "Mary", "Kelly"}).as("n")))
.select()
.from(name("dt"))
.fetch();
或者,这里有一个解包数组以获取随机值的示例:
ctx.with("dt")
.as(select().from(unnest(new String[]
{"John", "Mary", "Kelly"}).as("n")))
.select()
.from(name("dt"))
.orderBy(rand())
.limit(1)
.fetch();
或者,你可能需要一个数据库的随机样本(这里,10 个随机产品):
ctx.with("dt")
.as(selectFrom(PRODUCT).orderBy(rand()).limit(10))
.select()
.from(name("dt"))
.fetch();
然而,请注意,对于大型表,应避免使用ORDER BY RAND(),因为ORDER BY的执行效率为 O(N log N)。
如果你需要更复杂的数据来源,那么你可能会对生成一个序列感兴趣。以下是一个生成 1 到 10 之间奇数的示例:
ctx.with("dt")
.as(select().from(generateSeries(1, 10, 2).as("t", "s")))
.select()
.from(name("dt"))
.fetch();
这里有一个示例,将 1 到 100 之间的成绩与字母 A 到 F 相关联,并对其进行计数——换句话说,自定义成绩分箱:
ctx.with("grades")
.as(select(round(inline(70).plus(sin(
field(name("serie", "sample"), Integer.class))
.mul(30))).as("grade"))
.from(generateSeries(1, 100).as("serie", "sample")))
.select(
case_().when(field(name("grade")).lt(60), "F")
.when(field(name("grade")).lt(70), "D")
.when(field(name("grade")).lt(80), "C")
.when(field(name("grade")).lt(90), "B")
.else_("A").as("letter_grade"),count())
.from(name("grades"))
.groupBy(field(name("letter_grade")))
.orderBy(field(name("letter_grade")))
.fetch();
在捆绑的代码中,你可以看到更多分箱示例,包括通过PERCENT_RANK()进行自定义分箱、等高分箱、等宽分箱、PostgreSQL 的width_bucket()函数以及带有图表的分箱。
在所有这些片段之后,让我们解决以下著名问题:考虑p个学生班级的特定大小,以及q个房间的大小,其中q>= p。为适当大小的房间分配尽可能多的班级,编写一个 CTE。假设给定的数据在左侧图中,预期结果在右侧图中:

图 14.4 – 输入和预期输出
为了解决这个问题,我们可以按以下方式生成输入数据:
ctx.with("classes")
.as(select()
.from(values(row("c1", 80), row("c2", 70), row("c3", 65),
row("c4", 55), row("c5", 50), row("c6", 40))
.as("t", "class_nbr", "class_size")))
.with("rooms")
.as(select()
.from(values(row("r1", 70), row("r2", 40), row("r3", 50),
row("r4", 85), row("r5", 30), row("r6", 65), row("r7", 55))
.as("t", "room_nbr", "room_size")))
…
完整的查询太长了,无法在这里列出,但你可以在本节所有示例旁边的CteGenData应用程序中找到它。
动态 CTE
通常,当我们需要动态创建一个 CTE 时,我们计划动态地调整其名称、派生表和外层查询。例如,以下方法允许我们将这些组件作为参数传递,并返回查询的执行结果:
public Result<Record> cte(String cteName, Select select,
SelectField<?>[] fields, Condition condition,
GroupField[] groupBy, SortField<?>[] orderBy) {
var cte = ctx.with(cteName).as(select);
var cteSelect = fields == null
? cte.select() : cte.select(fields)
.from(table(name(cteName)));
if (condition != null) {
cteSelect.where(condition);
}
if (groupBy != null) {
cteSelect.groupBy(groupBy);
}
if (orderBy != null) {
cteSelect.orderBy(orderBy);
}
return cteSelect.fetch();
}
这里是解决前面提出的问题的调用示例,在CTEs 和窗口函数部分:
Result<Record> result = cte("t",
select(EMPLOYEE.EMPLOYEE_NUMBER.as("data_val"),
rowNumber().over().orderBy(EMPLOYEE.EMPLOYEE_NUMBER)
.as("data_seq"), EMPLOYEE.EMPLOYEE_NUMBER.minus(
rowNumber().over().orderBy(EMPLOYEE.EMPLOYEE_NUMBER))
.as("absent_data_grp"))
.from(EMPLOYEE),
new Field[]{field(name("absent_data_grp")), count(),
min(field(name("data_val"))).as("start_data_val")},
null, new GroupField[]{field(name("absent_data_grp"))},
null);
每次您尝试实现一个 CTE,就像这里一样,考虑以下 Lukas Eder 的笔记:这个示例使用 DSL 的可变方式,虽然可行但被不推荐。未来的 jOOQ 版本可能会转向不可变的 DSL API,而这段代码将不再工作。由于巨大的向后不兼容性,这不太可能很快发生,但现在的确是不推荐的。在 IntelliJ 中,您应该已经因为 API 的@CheckReturnValue注解的使用而收到警告,至少在 jOOQ 3.15 版本中是这样。
另一方面,如果您只需要将可变数量的 CTE 传递给外部查询,那么您可以这样做:
public void CTE(List<CommonTableExpression<?>> CTE) {
ctx.with(CTE)
...
}
或者,您可以这样做:
public void CTE(CommonTableExpression<?> cte1,
CommonTableExpression<?>cte2,
CommonTableExpression<?>cte3, ...) {
ctx.with(cte1, cte2, cte3)
...
}
您可以在CteDynamic中练习这些示例。
通过派生表、临时表和公用表表达式(CTE)表达查询
有时,我们更喜欢用几种不同的方式来表达一个查询,以便比较它们的执行计划。例如,我们可能有一个查询,并通过派生表、临时表和 CTE 来表达它,以查看哪种方法最适合。由于 jOOQ 支持这些方法,让我们从派生表方法开始尝试表达一个查询:
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
sum(SALE.SALE_), field(select(sum(SALE.SALE_)).from(SALE))
.divide(field(select(countDistinct(SALE.EMPLOYEE_NUMBER))
.from(SALE))).as("avg_sales"))
.from(EMPLOYEE)
.innerJoin(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
.groupBy(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME)
.having(sum(SALE.SALE_).gt(field(select(sum(SALE.SALE_))
.from(SALE))
.divide(field(select(countDistinct(SALE.EMPLOYEE_NUMBER))
.from(SALE))))).fetch();
因此,这个查询返回所有销售业绩高于平均水平的员工。对于每位员工,我们比较他们的平均销售业绩与所有员工的总平均销售业绩。本质上,这个查询是在EMPLOYEE和SALE表上工作的,我们必须知道所有员工的总销售业绩、员工数量以及每位员工的销售业绩总和。
如果我们将必须知道的信息提取到三个临时表中,那么我们得到如下结果:
ctx.createTemporaryTable("t1").as(
select(sum(SALE.SALE_).as("sum_all_sales"))
.from(SALE)).execute();
ctx.createTemporaryTable("t2").as(
select(countDistinct(SALE.EMPLOYEE_NUMBER)
.as("nbr_employee")).from(SALE)).execute();
ctx.createTemporaryTable("t3").as(
select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
sum(SALE.SALE_).as("employee_sale"))
.from(EMPLOYEE)
.innerJoin(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
.groupBy(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME))
.execute();
拥有这三个临时表后,我们可以将我们的查询重写如下:
ctx.select(field(name("first_name")),field(name("last_name")),
field(name("employee_sale")), field(name("sum_all_sales"))
.divide(field(name("nbr_employee"), Integer.class))
.as("avg_sales"))
.from(table(name("t1")),table(name("t2")), table(name("t3")))
.where(field(name("employee_sale")).gt(
field(name("sum_all_sales")).divide(
field(name("nbr_employee"), Integer.class))))
.fetch();
最后,相同的查询可以通过 CTE(通过将as()替换为asMaterialized(),您可以练习这个 CTE 的物化)来表达:
ctx.with("cte1", "sum_all_sales")
.as(select(sum(SALE.SALE_)).from(SALE))
.with("cte2", "nbr_employee")
.as(select(countDistinct(SALE.EMPLOYEE_NUMBER)).from(SALE))
.with("cte3", "first_name", "last_name", "employee_sale")
.as(select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
sum(SALE.SALE_).as("employee_sale"))
.from(EMPLOYEE)
.innerJoin(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER))
.groupBy(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME))
.select(field(name("first_name")),
field(name("last_name")), field(name("employee_sale")),
field(name("sum_all_sales"))
.divide(field(name("nbr_employee"), Integer.class))
.as("avg_sales"))
.from(table(name("cte1")), table(name("cte2")),
table(name("cte3")))
.where(field(name("employee_sale")).gt(
field(name("sum_all_sales")).divide(
field(name("nbr_employee"), Integer.class))))
.fetch();
现在您只需运行这些查询到您的数据库中,并比较它们的性能和执行计划。捆绑的代码包含一个额外的示例,并作为ToCte提供。
在 jOOQ 中处理视图
本章的最后部分留给了数据库视图。
视图充当一个实际的物理表,可以通过名称来调用。它们非常适合报告任务或与需要引导查询 API 的第三方工具集成。默认情况下,数据库供应商决定将视图的结果物化,或者依赖其他机制来达到相同的效果。大多数供应商(希望)不会默认物化视图!视图应该表现得就像 CTE 或派生表一样,并且应该对优化器透明。在大多数情况下(在 Oracle 中),我们期望视图被内联,即使被多次选择,因为每次都可能将不同的谓词推入视图。实际物化视图只由少数供应商支持,而优化器可以在视图被多次查询时决定物化视图内容。视图的定义存储在模式表中,因此可以在任何可以使用常规/基本表的地方通过名称调用。如果视图是可更新的,那么一些额外的规则将用来支持它。
视图在几个基本方面与基础、临时或派生表不同。基础和临时表可以接受约束,而视图则不能(在大多数数据库中)。视图在数据库中不存在,直到被调用,而临时表是持久的。最后,派生表的范围与创建它的查询相同。视图定义不能包含对自身的引用,因为它还不存在,但它可以包含对其他视图的引用。
视图的基本语法如下(对于特定数据库供应商的确切语法,应查阅文档):
CREATE VIEW <table name> [(<view column list>)]
AS <query expression>
[WITH [<levels clause>] CHECK OPTION]
<levels clause>::= CASCADED | LOCAL
一些关系型数据库管理系统(RDBMS)支持对视图的约束(例如,Oracle),尽管存在限制:docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/constraint.html。文档中提到的 WITH CHECK OPTION 实际上是一个约束。
接下来,让我们看看通过 jOOQ 表达的一些视图示例。
可更新和只读视图
视图可以是可更新的或只读的,但不能同时是两者。在 jOOQ 中,可以通过 createView() 和 createViewIfNotExists() 方法创建视图。删除视图可以通过 dropView() 或 dropViewIfExists() 来实现。以下是一个创建只读视图的示例:
ctx.createView("sales_1504_1370")
.as(select().from(SALE).where(
SALE.EMPLOYEE_NUMBER.eq(1504L))
.unionAll(select().from(SALE)
.where(SALE.EMPLOYEE_NUMBER.eq(1370L))))
.execute();
大概来说,在标准 SQL 中,可更新的视图仅基于一个表;它不能包含 GROUP BY、HAVING、INTERSECT、EXCEPT、SELECT DISTINCT 或 UNION(然而,至少在理论上,两个不相交表之间的 UNION,其中任何一个表都没有重复行,应该是可更新的),聚合函数、计算列,以及任何排除在视图之外的列必须在基础表中具有 DEFAULT 或是可空的。然而,根据标准 SQL T111 可选功能,连接和联合本身并不是不可更新的障碍,因此可更新的视图不必仅基于一个表。另外(为了避免任何疑问),可更新的视图的所有列不必都是可更新的,但当然,只有可更新的列才能被更新。
当视图被修改时,修改会通过视图传递到相应的底层基础表。换句话说,一个可更新的视图与其底层基础表的行之间有一个 1:1 的匹配,因此之前的视图是不可更新的。但我们可以通过去掉 UNION ALL 来重写它,使其成为一个有效的可更新视图:
ctx.createView("sales_1504_1370_u")
.as(select().from(SALE)
.where(SALE.EMPLOYEE_NUMBER.in(1504L, 1370L)))
.execute();
一些视图是“部分”可更新的。例如,包含如下 JOIN 语句的视图:
ctx.createView("employees_and_sales", "first_name",
"last_name", "sale_id", "sale")
.as(select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
SALE.SALE_ID, SALE.SALE_)
.from(EMPLOYEE)
.join(SALE)
.on(EMPLOYEE.EMPLOYEE_NUMBER.eq(SALE.EMPLOYEE_NUMBER)))
.execute();
虽然在 PostgreSQL 中这个视图根本不可更新,但在 MySQL、SQL Server 和 Oracle 中,这个视图是“部分”可更新的。换句话说,只要修改只影响两个涉及的基础表中的一个,视图就是可更新的,否则则不可更新。如果涉及更多基础表进行更新,则会发生错误。例如,在 SQL Server 中,我们会得到一个错误“视图或函数 'employees_and_sales' 不可更新,因为修改影响了多个基础表”,而在 Oracle 中,我们会得到 ORA-01776。
你可以在 DbViews 中查看这些示例。
视图的类型(非官方分类)
在本节中,让我们根据它们的用途定义几种常见的视图类型,并从单表投影和限制类型的视图开始。
单表投影和限制
有时,出于安全考虑,我们依赖于单个基础表的投影/限制来删除某些不应被特定用户组看到的行和/或列。例如,以下视图表示对 BANK_TRANSACTION 基础表的投影,以限制/隐藏涉及银行的详细信息:
ctx.createView("transactions",
"customer_number", "check_number",
"caching_date", "transfer_amount", "status")
.as(select(BANK_TRANSACTION.CUSTOMER_NUMBER,
BANK_TRANSACTION.CHECK_NUMBER,
BANK_TRANSACTION.CACHING_DATE,
BANK_TRANSACTION.TRANSFER_AMOUNT,
BANK_TRANSACTION.STATUS)
.from(BANK_TRANSACTION))
.execute();
另一种类型的视图处理计算列。
计算列
提供汇总数据是视图的另一个用例。例如,我们更喜欢尽可能有意义的计算列,并将它们作为视图暴露给客户端。以下是一个计算每个员工的工资加佣金的示例:
ctx.createView("payroll", "employee_number", "paycheck_amt")
.as(select(EMPLOYEE.EMPLOYEE_NUMBER, EMPLOYEE.SALARY
.plus(coalesce(EMPLOYEE.COMMISSION, 0.00)))
.from(EMPLOYEE))
.execute();
另一种类型的视图处理翻译列。
翻译列
视图还可以用于将代码转换为文本,以增加检索结果集的可读性。一个常见的例子是通过一个或多个外键在几个表之间通过一系列 JOIN 语句。例如,在以下视图中,我们通过转换 CUSTOMER_NUMBER、ORDER_ID 和 PRODUCT_ID 代码(外键)来获得客户、订单和产品的详细报告:
ctx.createView("customer_orders")
.as(select(CUSTOMER.CUSTOMER_NAME,
CUSTOMER.CONTACT_FIRST_NAME, CUSTOMER.CONTACT_LAST_NAME,
ORDER.SHIPPED_DATE, ORDERDETAIL.QUANTITY_ORDERED,
ORDERDETAIL.PRICE_EACH, PRODUCT.PRODUCT_NAME,
PRODUCT.PRODUCT_LINE)
.from(CUSTOMER)
.innerJoin(ORDER)
.on(CUSTOMER.CUSTOMER_NUMBER.eq(ORDER.CUSTOMER_NUMBER))
.innerJoin(ORDERDETAIL)
.on(ORDER.ORDER_ID.eq(ORDERDETAIL.ORDER_ID))
.innerJoin(PRODUCT)
.on(ORDERDETAIL.PRODUCT_ID.eq(PRODUCT.PRODUCT_ID)))
.execute();
接下来,让我们处理分组视图。
分组视图
分组视图依赖于包含 GROUP BY 子句的查询。通常,这些只读视图包含一个或多个聚合函数,并且对于创建不同类型的报告非常有用。以下是一个创建分组视图的示例,该视图检索每个员工的销售额:
ctx.createView("big_sales", "employee_number", "big_sale")
.as(select(SALE.EMPLOYEE_NUMBER, max(SALE.SALE_))
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER))
.execute();
这里是另一个示例,它依赖于一个分组视图来“扁平化”一对多关系:
ctx.createView("employee_sales",
"employee_number", "sales_count")
.as(select(SALE.EMPLOYEE_NUMBER, count())
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER))
.execute();
var result = ctx.select(
EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
coalesce(field(name("sales_count")), 0)
.as("sales_count"))
.from(EMPLOYEE)
.leftOuterJoin(table(name("employee_sales")))
.on(EMPLOYEE.EMPLOYEE_NUMBER
.eq(field(name("employee_sales", "employee_number"),
Long.class))).fetch();
接下来,让我们处理 UNION 合并的视图。
UNION 合并的视图
在视图中使用 UNION/UNION ALL 也是一种常见的视图用法。以下是通过 UNION 重写的先前查询,用于扁平化一对多关系:
ctx.createView("employee_sales_u",
"employee_number", "sales_count")
.as(select(SALE.EMPLOYEE_NUMBER, count())
.from(SALE)
.groupBy(SALE.EMPLOYEE_NUMBER)
.union(select(EMPLOYEE.EMPLOYEE_NUMBER, inline(0))
.from(EMPLOYEE)
.whereNotExists(select().from(SALE)
.where(SALE.EMPLOYEE_NUMBER
.eq(EMPLOYEE.EMPLOYEE_NUMBER))))).execute();
var result = ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, field(name("sales_count")))
.from(EMPLOYEE)
.innerJoin(table(name("employee_sales_u")))
.on(EMPLOYEE.EMPLOYEE_NUMBER
.eq(field(name("employee_sales_u", "employee_number"),
Long.class)))
.fetch();
最后,让我们看看嵌套视图的示例。
嵌套视图
视图可以建立在另一个视图之上。请注意,在视图的查询表达式中避免循环引用,并且不要忘记视图最终必须建立在基础表上。此外,如果你同时有多个可更新的视图引用相同的基表,请注意。在其他视图中使用此类视图可能会导致歧义问题,因为很难推断如果最高级视图被修改会发生什么。
这里是一个使用嵌套视图的示例:
ctx.createView("customer_orders_1",
"customer_number", "orders_count")
.as(select(ORDER.CUSTOMER_NUMBER, count())
.from(ORDER)
.groupBy(ORDER.CUSTOMER_NUMBER)).execute();
ctx.createView("customer_orders_2", "first_name",
"last_name", "orders_count")
.as(select(CUSTOMER.CONTACT_FIRST_NAME,
CUSTOMER.CONTACT_LAST_NAME,
coalesce(field(name("orders_count")), 0))
.from(CUSTOMER)
.leftOuterJoin(table(name("customer_orders_1")))
.on(CUSTOMER.CUSTOMER_NUMBER
.eq(field(name("customer_orders_1",
"customer_number"), Long.class)))).execute();
第一个视图 customer_orders_1 统计每个客户的总订单数,第二个视图 customer_orders_2 检索这些客户的名称。
你可以在 DbTypesOfViews 中看到这些示例。
视图的一些示例
在本节中,我们依靠视图来解决几个问题。例如,以下视图用于通过每个办公室的员工人数计算累积分布值:
ctx.createView("office_headcounts",
"office_code", "headcount")
.as(select(OFFICE.OFFICE_CODE,
count(EMPLOYEE.EMPLOYEE_NUMBER))
.from(OFFICE)
.innerJoin(EMPLOYEE)
.on(OFFICE.OFFICE_CODE.eq(EMPLOYEE.OFFICE_CODE))
.groupBy(OFFICE.OFFICE_CODE))
.execute();
接下来,使用此视图计算累积分布的查询如下:
ctx.select(field(name("office_code")),
field(name("headcount")),
round(cumeDist().over().orderBy(
field(name("headcount"))).mul(100), 2)
.concat("%").as("cume_dist_val"))
.from(name("office_headcounts"))
.fetch();
视图可以与公用表表达式(CTE)结合使用。以下是一个示例,它创建了一个视图,用于在 CTE 上检测 ID 中的差距——这是在 CTE 和窗口函数 部分中解决的问题:
ctx.createView("absent_values",
"data_val", "data_seq", "absent_data_grp")
.as(with("t", "data_val", "data_seq", "absent_data_grp")
.as(select(EMPLOYEE.EMPLOYEE_NUMBER,
rowNumber().over().orderBy(EMPLOYEE.EMPLOYEE_NUMBER),
EMPLOYEE.EMPLOYEE_NUMBER.minus(rowNumber().over()
.orderBy(EMPLOYEE.EMPLOYEE_NUMBER)))
.from(EMPLOYEE))
.select(field(name("absent_data_grp")), count(),
min(field(name("data_val"))).as("start_data_val"))
.from(name("t"))
.groupBy(field(name("absent_data_grp"))))
.execute();
查询很简单:
ctx.select().from(name("absent_values")).fetch();
ctx.selectFrom(name("absent_values")).fetch();
最后,让我们看看一个尝试根据 2003 年的历史数据优化未来运输成本的示例。假设我们与一家可以按需提供每年卡车可用期列表的专门公司运输订单:
Table truck = select().from(values(
row("Truck1",LocalDate.of(2003,1,1),LocalDate.of(2003,1,12)),
row("Truck2",LocalDate.of(2003,1,8),LocalDate.of(2003,1,27)),
...
)).asTable("truck", "truck_id", "free_from", "free_to");
提前为特定时间段预订卡车可以利用某些折扣,因此,基于 2003 年的订单,我们可以分析一些查询,这些查询可以告诉我们此操作是否可以优化运输成本。
我们从一个名为 order_truck 的视图开始,它告诉我们每个订单可用的卡车:
ctx.createView("order_truck", "truck_id", "order_id")
.as(select(field(name("truck_id")), ORDER.ORDER_ID)
.from(truck, ORDER)
.where(not(field(name("free_to")).lt(ORDER.ORDER_DATE)
.or(field(name("free_from")).gt(ORDER.REQUIRED_DATE)))))
.execute();
基于这个视图,我们可以运行几个提供重要信息的查询。例如,每辆卡车可以运送多少订单?
ctx.select(field(name("truck_id")), count().as("order_count"))
.from(name("order_truck"))
.groupBy(field(name("truck_id")))
.fetch();
或者,有多少卡车可以运送相同的订单?
ctx.select(field(name("order_id")), count()
.as("truck_count"))
.from(name("order_truck"))
.groupBy(field(name("order_id")))
.fetch();
此外,基于这个视图,我们可以创建另一个名为order_truck_all的视图,它可以告诉我们两个区间中的最早和最晚时间点:
ctx.createView("order_truck_all", "truck_id",
"order_id", "entry_date", "exit_date")
.as(select(field(name("t", "truck_id")),
field(name("t", "order_id")),
ORDER.ORDER_DATE, ORDER.REQUIRED_DATE)
.from(table(name("order_truck")).as("t"), ORDER)
.where(ORDER.ORDER_ID.eq(field(name("t", "order_id"),
Long.class)))
.union(select(field(name("t", "truck_id")),
field(name("t", "order_id")),
truck.field(name("free_from")),
truck.field(name("free_to")))
.from(table(name("order_truck")).as("t"), truck)
.where(truck.field(name("truck_id"))
.eq(field(name("t", "truck_id"))))))
.execute();
根据前面的视图,我们可以确定两个区间中的确切时间点如下:
ctx.createView("order_truck_exact", "truck_id",
"order_id", "entry_date", "exit_date")
.as(select(field(name("truck_id")),
field(name("order_id")),
max(field(name("entry_date"))),
min(field(name("exit_date"))))
.from(name("order_truck_all"))
.groupBy(field(name("truck_id")),
field(name("order_id"))))
.execute();
根据我们想要分析数据的深度,我们可以继续添加更多查询和视图,但我认为你已经明白了这个概念。你可以在捆绑的代码中查看这些示例,这些代码名为DbViewsEx。
对于那些期望在这里涵盖表值函数(也称为“参数化视图”)的人来说,请考虑下一章内容。
另一方面,在本章中,你看到了几个用于触发 DDL 语句的 jOOQ 方法,例如createView()、createTemporaryTable()等等。实际上,jOOQ 提供了一个全面的 API,用于以编程方式生成 DDL,这些示例在捆绑的代码中名为DynamicSchema。请花时间练习这些示例,并熟悉它们。
摘要
在本章中,你已经学会了如何在 jOOQ 中表达派生表、CTE 和视图。由于这些是强大的 SQL 工具,因此熟悉它们非常重要。因此,除了本章中的示例之外,建议你自己挑战自己,尝试通过 jOOQ 的 DSL 解决更多问题。
在下一章中,我们将处理存储函数/过程。
第十五章:调用和创建存储函数和过程
SQL 是一种声明性语言,但它也具有存储函数/过程、触发器和游标等程序性功能,这意味着 SQL 被认为是第四代编程语言(4GL)。在本章中,我们将看到如何调用和创建存储函数/过程,换句话说,如何调用和创建 MySQL、PostgreSQL、SQL Server 和 Oracle 的持久化存储模块(SQL/PSM)。
如果你需要快速回顾存储过程和函数之间的关键区别,请查看以下对头对头的表格(以下差异根据数据库的不同,有些是完全或部分正确的):

]
图 15.1 – 程序和函数的关键区别
如您从前面的比较中可以推断出的,主要区别在于程序(可能)会产生副作用,而函数(通常)预期不会。
因此,我们的议程包括以下主题:
-
从 jOOQ 调用存储函数/过程
-
存储过程
-
通过 jOOQ 创建存储函数/过程
在开始之前,让我们从 Lukas Eder 那里得到一些见解,他分享说:“这可能会稍后出现,但可能值得早点提一下:有一些用户只使用 jOOQ 的存储过程代码生成功能。当你有很多存储过程时,没有代码生成几乎不可能将它们绑定起来,而 jOOQ 开箱即用,有点像当你有一个 WSDL 文件(或类似的东西),然后使用 Axis 或 Metro 等生成所有存根,等等。”
好的,现在让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter15。
从 jOOQ 调用存储函数/过程
一旦你开始处理不同数据库供应商的存储函数/过程,你将面临标准化的缺乏。例如,用于表示函数/过程的供应商特定语法、供应商特定函数/过程类型的广泛多样性,以及支持和处理输出参数的不同方式,只是存储函数/过程的几个非标准化方面。
通过纯 JDBC 代码调用存储函数/过程也不容易,尤其是当涉及到高级数据类型时(例如,数组或 UDTs)。但是,正如你所知道的那样,使用 jOOQ DSL 可以让我们避免直接与 JDBC API 交互,因此它节省了我们做出关于 JDBC 解决方案的尖端决策。
jOOQ DSL 通过org.jooq.Routine API 表示存储函数/过程,因此有一个共同的 API。每当 jOOQ 生成器检测到一个存储函数/过程时,它会在适当的包中生成一个专用类(在我们的例子中,是jooq.generated.routines),该类反映了其名称(例如,默认情况下,名为get_emps_in_office()的存储函数会产生名为GetEmpsInOffice的类)并扩展了 jOOQ 的AbstractRoutine类。生成的类公开了通过 jOOQ DSL 调用此存储函数/过程的 API。此外,正如您很快就会看到的,调用存储函数/过程也可以通过DSLContext.begin()和直接通过DSLContext.call()在匿名过程块中完成。但是,理论就到这里,接下来让我们从 jOOQ 的角度探讨一些不同类型的存储函数/过程,让我们从存储函数开始。
存储函数
存储函数返回一个结果(例如,计算的输出)。它们可以在 SQL 语句中调用,并且通常不支持输出(OUT)参数。然而,在 Oracle 和 PostgreSQL 中,存储函数可能有输出参数,这些参数可以解释为返回结果。此外,直到版本 11,PostgreSQL 只支持结合存储函数和过程特征的存储函数。另一方面,PostgreSQL 11 及以后,Oracle、MySQL 和 SQL Server 在存储函数和过程之间进行了区分。
接下来,让我们看看我们如何从 jOOQ 调用用这四种方言之一表达的不同类型的存储函数,让我们先调用一些标量函数。
标量函数
一个接受零个、一个或多个参数并返回单个值的存储函数通常被称为标量函数。作为一种常见做法,标量函数封装了出现在许多查询中的复杂计算。您不必在每次查询中都表达这个计算,而是可以编写一个封装此计算的标量函数,并在每次查询中使用它。大致来说,标量函数的语法是这个骨架的变体:
CREATE FUNCTION name (parameters)
RETURNS data_type AS
BEGIN
statements/computations
RETURN value
END
例如,一个简单的标量函数在 MySQL 中可能看起来如下所示:
DELIMITER $$
CREATE FUNCTION `sale_price`(
`quantity` INT, `list_price` REAL, `fraction_of_price` REAL)
RETURNS REAL
DETERMINISTIC
BEGIN
RETURN (`list_price` -
(`list_price` * `fraction_of_price`)) * `quantity`;
END $$
DELIMITER ;
对于这个标量函数,jOOQ 代码生成器生成一个名为jooq.generated.routines.SalePrice的专用类。在其方法中,这个类公开了允许我们提供函数输入参数的设置器。在我们的例子中,我们将有setQuantity(Integer value)、setQuantity(Field<Integer> field)、setListPrice(Double value)、setListPrice(Field<Double> field)、setFractionOfPrice(Double value)和setFractionOfPrice(Field<Double> field)。函数可以通过 jOOQ 的知名execute()方法执行。如果函数已经附加了Configuration,则可以依赖execute()不带参数,否则使用execute(Configuration c)方法,如下所示:
SalePrice sp = new SalePrice();
sp.setQuantity(25);
sp.setListPrice(15.5);
sp.setFractionOfPrice(0.75);
sp.execute(ctx.configuration());
通过 getReturnValue() 方法获取返回的标量结果。在这种情况下,你可以这样使用它:
double result = sp.getReturnValue();
正如你所看到的,每个设置器都有一个获取 Field 作为参数的风味。这意味着我们可以写点这样的东西(检查前两个设置器):
sp.setQuantity(field(select(PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCT).where(PRODUCT.PRODUCT_ID.eq(1L))));
sp.setListPrice(field(select(PRODUCT.MSRP.coerce(Double.class))
.from(PRODUCT).where(PRODUCT.PRODUCT_ID.eq(1L))));
sp.setFractionOfPrice(0.75);
sp.execute(ctx.configuration());
double result = sp.getReturnValue();
如果例程有超过 254 个参数(Java 中不允许),或者有默认参数,用户不想设置,或者参数需要动态设置,那么前面的示例将很有用。否则,你很可能更愿意使用静态便利 API。
通过 Routines.salePrice() 静态方法以更方便/紧凑的方式编写这两个示例。jooq.generated.Routines 类提供了方便的静态方法来访问 jOOQ 在你的数据库中找到的所有存储函数/过程。在这种情况下,以下两个示例紧凑了前面的示例(当然,你可以通过导入 jooq.generated.Routines.salePrice 静态进一步缩短这个示例):
double sp = Routines.salePrice(
ctx.configuration(), 25, 15.5, 0.75);
Field<Float> sp = Routines.salePrice(
field(select(PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCT).where(PRODUCT.PRODUCT_ID.eq(1L))),
field(select(PRODUCT.MSRP.coerce(Double.class))
.from(PRODUCT).where(PRODUCT.PRODUCT_ID.eq(1L))),
val(0.75));
double sp = ctx.fetchValue(salePrice(
field(select(PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCT).where(PRODUCT.PRODUCT_ID.eq(1L))),
field(select(PRODUCT.MSRP.coerce(Double.class))
.from(PRODUCT).where(PRODUCT.PRODUCT_ID.eq(1L))),
val(0.75)));
标量函数也可以用在查询中。这里是一个通过 Routines.salePrice() 的另一种风味示例:
ctx.select(ORDERDETAIL.ORDER_ID,
sum(salePrice(ORDERDETAIL.QUANTITY_ORDERED,
ORDERDETAIL.PRICE_EACH.coerce(Double.class),
val(0.75))).as("sum_sale_price"))
.from(ORDERDETAIL)
.groupBy(ORDERDETAIL.ORDER_ID)
.orderBy(field(name("sum_sale_price")).desc())
.fetch();
对于 MySQL,jOOQ 生成的以下 SQL 是这样的:
SELECT `classicmodels`.`orderdetail`.`order_id`,
sum(`classicmodels`.`sale_price`(
`classicmodels`.`orderdetail`.`quantity_ordered`,
`classicmodels`.`orderdetail`.`price_each`, 7.5E-1))
AS `sum_sale_price`
FROM `classicmodels`.`orderdetail`
GROUP BY `classicmodels`.`orderdetail`.`order_id`
ORDER BY `sum_sale_price` DESC
你可以在 ScalarFunction 中练习这个示例。
在这个上下文中,这里还有一个为 PostgreSQL 编写的函数,它更新 PRODUCT.MSRP 并通过 UPDATE … RETURNING 返回(不要与 RETURN 语句混淆):
CREATE OR REPLACE FUNCTION "update_msrp" (
"id" BIGINT, "debit" INTEGER) RETURNS REAL AS $$
UPDATE "public"."product"
SET "msrp" = "public"."product"."msrp" - "debit"
WHERE "public"."product"."product_id" = "id"
RETURNING "public"."product"."msrp";
$$ LANGUAGE SQL;
jOOQ 可以像上一个示例中看到的那样调用这样的函数。例如,这里是在一个 SELECT 中调用的:
ctx.select(PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_NAME,
PRODUCT.MSRP.as("obsolete_msrp"),
updateMsrp(PRODUCT.PRODUCT_ID, inline(50)))
.from(PRODUCT).fetch();
生成的 SQL 如下所示:
SELECT "public"."product"."product_id",
"public"."product"."product_name",
"public"."product"."msrp" AS "obsolete_msrp",
"public"."update_msrp"("id" := "public"
."product"."product_id", "debit" := 50)
FROM "public"."product"
你可以在 UpdateFunction 中为 PostgreSQL 练习这个示例。
如果你的标量函数在 Oracle 上,那么你可以利用一个称为 标量子查询缓存 的很好的 Oracle 功能。基本上,这个功能将存储函数的调用封装在 SQL 语句中,作为标量子查询。这个功能避免了在 PL/SQL 和 SQL 上下文之间切换,这可能会导致更好的性能。
考虑以下标量函数:
CREATE OR REPLACE NONEDITIONABLE FUNCTION
"card_commission"("card_type" IN VARCHAR2)
RETURN NUMBER IS
"commission" NUMBER := 0;
BEGIN
RETURN CASE "card_type"
WHEN 'VisaElectron' THEN .15
WHEN 'Mastercard' THEN .22
ELSE .25
END;
END;
在查询中调用这个标量函数可以在这里完成:
ctx.select(cardCommission(BANK_TRANSACTION.CARD_TYPE))
.from(BANK_TRANSACTION)
.fetch();
List<BigDecimal> commission = ctx.fetchValues(
select(cardCommission(BANK_TRANSACTION.CARD_TYPE))
.from(BANK_TRANSACTION));
但请记住卢卡斯·埃德(Lukas Eder)的笔记:“据我所知,标量子查询缓存在 Oracle 中没有文档。上下文切换并没有完全避免,但它只发生在每个标量子查询输入值、每个函数参数值和每个查询上。因此,我们现在每个函数输入值只有一个切换,而不是每行有一个切换。”
每次我们执行这段代码时,jOOQ 都会生成一个查询,该查询需要在 PL/SQL 和 SQL 上下文之间切换以执行标量函数:
SELECT "CLASSICMODELS"."card_commission"(
"CLASSICMODELS"."BANK_TRANSACTION"."CARD_TYPE")
FROM "CLASSICMODELS"."BANK_TRANSACTION"
但 jOOQ 有一个 withRenderScalarSubqueriesForStoredFunctions() 标志设置,默认设置为 false。一旦我们将它设置为 true,jOOQ 就会开启 Oracle 的 标量子查询缓存 功能。在下面的示例中,我们只为当前的 SQL 开启这个功能:
ctx.configuration().derive(new Settings()
.withRenderScalarSubqueriesForStoredFunctions(true))
.dsl()
.select(cardCommission(BANK_TRANSACTION.CARD_TYPE))
.from(BANK_TRANSACTION)
.fetch();
这次,cardCommission() 存储函数的调用被渲染为一个标量子查询:
SELECT
(SELECT "CLASSICMODELS"."card_commission"(
"CLASSICMODELS"."BANK_TRANSACTION"."CARD_TYPE")
FROM DUAL)
FROM "CLASSICMODELS"."BANK_TRANSACTION"
你可以在ScalarSubqueryCaching(针对 Oracle)中练习这个示例。
返回数组的函数
PostgreSQL 是编写返回数组的函数的便捷方式。例如,这里是一个返回DEPARTMENT.TOPIC的函数,它在我们的模式中声明为一个TEXT[]类型的数组:
CREATE OR REPLACE FUNCTION "department_topic_arr"
(IN "id" BIGINT)
RETURNS TEXT[]
AS $$
SELECT "public"."department"."topic"
FROM "public"."department" WHERE
"public"."department"."department_id" = "id"
$$ LANGUAGE SQL;
通过 jOOQ 在Routines中生成的专用方法departmentTopicArr()在SELECT中调用此函数,可以通过以下示例中的展开返回的数组来完成:
ctx.select().from(unnest(departmentTopicArr(2L))
.as("t")).fetch();
ctx.fetch(unnest(departmentTopicArr(2L)).as("t"));
接下来,让我们考虑一个具有匿名参数(没有显式名称)的函数,该函数构建并返回一个数组:
CREATE OR REPLACE FUNCTION "employee_office_arr"(VARCHAR(10))
RETURNS BIGINT[]
AS $$
SELECT ARRAY(SELECT "public"."employee"."employee_number"
FROM "public"."employee" WHERE "public"."employee"
."office_code" = $1)
$$ LANGUAGE sql;
这次,让我们实例化EmployeeOfficeArr,并通过设置器传递所需的参数:
EmployeeOfficeArr eoa = new EmployeeOfficeArr();
eoa.set__1("1");
eoa.execute(ctx.configuration());
Long[] result = eoa.getReturnValue();
由于函数的参数没有名称,jOOQ 使用了其默认实现并生成了一个set__1()设置器。如果你有两个无名称的参数,那么 jOOQ 将生成set__1()和set__2(),依此类推。换句话说,jOOQ 根据参数位置从 1 开始生成设置器。
另一方面,在SELECT查询中使用生成的Routines.employeeOfficeArr()可以如下进行:
ctx.select(field(name("t", "en")), sum(SALE.SALE_))
.from(SALE)
.rightJoin(unnest(employeeOfficeArr("1")).as("t", "en"))
.on(field(name("t", "en")).eq(SALE.EMPLOYEE_NUMBER))
.groupBy(field(name("t", "en"))).fetch();
对于 PostgreSQL,jOOQ 生成的 SQL 如下:
SELECT "t"."en",sum("public"."sale"."sale")
FROM "public"."sale"
RIGHT OUTER JOIN unnest("public"."employee_office_arr"('1'))
AS "t" ("en")
ON "t"."en" = "public"."sale"."employee_number"
GROUP BY "t"."en"
你可以在ArrayFunction(针对 PostgreSQL)中练习这些示例。
带有输出参数的函数
正如我们之前所说的,PostgreSQL 和 Oracle 允许函数中有输出参数。以下是一个 PostgreSQL 中的示例:
CREATE OR REPLACE FUNCTION "get_salary_stat"(
OUT "min_sal" INT, OUT "max_sal" INT, OUT "avg_sal" NUMERIC)
LANGUAGE plpgsql
AS $$
BEGIN
SELECT MIN("public"."employee"."salary"),
MAX("public"."employee"."salary"),
AVG("public"."employee"."salary")::NUMERIC(7,2)
INTO "min_sal", "max_sal", "avg_sal"
FROM "public"."employee";
END;
$$;
这个函数没有RETURN,但它有三个OUT参数,帮助我们获得执行结果。对于每个这样的参数,jOOQ 生成一个 getter,因此我们可以通过生成的jooq.generated.routines.GetSalaryStat来调用它,如下所示:
GetSalaryStat salStat = new GetSalaryStat();
salStat.execute(ctx.configuration());
Integer minSal = salStat.getMinSal();
Integer maxSal = salStat.getMaxSal();
BigDecimal avgSal = salStat.getAvgSal();
这段代码(更确切地说,是execute()调用)导致以下SELECT(或者在 Oracle 中是CALL):
SELECT "min_sal", "max_sal", "avg_sal"
FROM "public"."get_salary_stat"()
通过Routines.getSalaryStat(Configuration c)也可以得到相同的结果:
GetSalaryStat salStat = getSalaryStat(ctx.configuration());
// call the getters
这里还有两个更多示例:
Integer minSal = ctx.fetchValue(val(getSalaryStat(
ctx.configuration()).getMinSal()));
ctx.select(EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME,
EMPLOYEE.SALARY)
.from(EMPLOYEE)
.where(EMPLOYEE.SALARY.coerce(BigDecimal.class)
.gt(getSalaryStat(ctx.configuration()).getAvgSal()))
.fetch();
但请注意,这两个示例都会导致两个SELECT语句(或者在 Oracle 中是一个CALL和一个SELECT语句),因为带有输出参数的函数不能从纯 SQL 中调用。换句话说,jOOQ 调用例程并检索您接下来可以看到的OUT参数:
SELECT "min_sal","max_sal","avg_sal"
FROM "public"."get_salary_stat"()
然后,它执行使用从输出参数中提取的结果的SELECT。以下SELECT符合我们的第二个示例(65652.17 是通过getSalaryStat(Configuration c)计算出的平均工资):
SELECT "public"."employee"."first_name",
"public"."employee"."last_name",
"public"."employee"."salary"
FROM "public"."employee"
WHERE "public"."employee"."salary" > 65652.17
Lukas Eder 分享说:“在 Oracle 中,带有 OUT 参数的函数并不是“SQL 可调用的”,尽管如此...在 PostgreSQL 中,它们只是函数返回记录的“语法糖”(或者根据口味,是“去糖”)。”
你可以在InOutFunction(针对 PostgreSQL)中练习这些示例(在这里,你还可以找到一个IN OUT示例)。
因此,在函数中使用 OUT(或 IN OUT)参数并不是一个好主意,必须避免。正如 Oracle 所说,除了防止函数在 SQL 查询中使用(更多细节请参阅这里)之外,函数中输出参数的存在还防止了函数被标记为 DETERMINISTIC 函数或用作结果缓存的函数。与 PostgreSQL 不同,具有输出参数的 Oracle 函数必须有一个显式的 RETURN。在 jOOQ 专门用于输出参数的获取器旁边,你可以调用 getReturnValue() 来获取通过 RETURN 语句显式返回的结果。当这本书编写时,具有 OUT 参数的函数不能在 jOOQ 中从纯 SQL 调用。在此功能请求中查看:github.com/jOOQ/jOOQ/issues/3426。
你可以在 InOutFunction 的 Oracle 示例中进行练习(在这里,你还可以找到一个 IN OUT 示例)。
多态函数
一些数据库支持所谓的多态函数,这些函数可以接受和返回多态类型。例如,PostgreSQL 支持以下多态类型:anyelement、anyarray、anynonarray、anyenum 和 anyrange。以下是一个从两个传递的任意数据类型元素构建数组的示例:
CREATE FUNCTION "make_array"(anyelement, anyelement)
RETURNS anyarray
AS $$
SELECT ARRAY[$1, $2];
$$ LANGUAGE SQL;
通过 jOOQ 调用此函数可以这样进行(注意位置设置器再次起作用):
MakeArray ma = new MakeArray();
ma.set__1(1);
ma.set__2(2);
ma.execute(ctx.configuration());
返回的结果是 org.postgresql.jdbc.PgArray:
PgArray arr = (PgArray) ma.getReturnValue();
在捆绑的代码中,你可以看到对这个数组的进一步处理。现在,让我们从 SELECT 中调用 make_array() 来构建一个整数数组和字符串数组:
ctx.select(makeArray(1, 2).as("ia"),
makeArray("a", "b").as("ta")).fetch();
那么一个结合多态类型和输出参数的函数呢?这里有一个示例:
CREATE FUNCTION "dup"(IN "f1" anyelement,
OUT "f2" anyelement, OUT "f3" anyarray)
AS 'select $1, array[$1,$1]' LANGUAGE sql;
通过 jOOQ 调用它:
Dup dup = new Dup();
dup.setF1(10);
dup.execute(ctx.configuration());
// call here getF2() and/or getF3()
或者,在 SELECT 中使用它(记住,从上一节中提到的,这将导致对数据库的两个语句):
ctx.select(val(dup(ctx.configuration(), 10).getF2())).fetch();
ctx.fetchValue(val(dup(ctx.configuration(), 10).getF2()));
你可以在 PolymorphicFunction 的 PostgreSQL 中练习这些示例。
返回显式游标的函数
函数还可以返回一个显式游标(一个指向查询结果的显式指针)。你很可能熟悉 PostgreSQL 的 REFCURSOR 和 Oracle 的 SYS_REFCURSOR。以下是一个 Oracle 的示例:
CREATE OR REPLACE NONEDITIONABLE FUNCTION
"GET_CUSTOMER" ("cl" IN NUMBER)
RETURN SYS_REFCURSOR
AS "cur" SYS_REFCURSOR;
BEGIN
OPEN "cur" FOR
SELECT *
FROM "CUSTOMER"
WHERE "CUSTOMER"."CREDIT_LIMIT" > "cl"
ORDER BY "CUSTOMER"."CUSTOMER_NAME";
RETURN "cur";
END;
通过生成的 jooq.generated.routines.GetCustomer 函数调用可以这样进行:
GetCustomer customers = new GetCustomer();
customers.setCl(120000);
customers.execute(ctx.configuration());
Result<Record> result = customers.getReturnValue();
结果被 jOOQ 映射为 Result<Record>,因此记录列表完全适合内存。为了更好地适应大数据集,jOOQ 有一些待处理的功能请求以流式传输游标,因此你可以检查 jOOQ 仓库中的问题 #4503 和 #4472。或者,你可以将返回的游标包装在一个表值函数中,并通过 jOOQ 的 ResultQuery.fetchLazy() 方法使用 SELECT 语句获取结果,就像你在 第八章 的 获取和映射 中看到的那样。
现在,更进一步,你可以循环 Result<Record> 并处理每条记录,但要访问某一行(例如,第一行)的某个列(例如,客户名称),那么你可以使用 getValue() 或 get(),就像这里一样:
String name = (String) result.getValue(0, "CUSTOMER_NAME");
String name = result.get(0).get("CUSTOMER_NAME", String.class);
要获取多个名称(或其他列),请依赖于 getValues(),它有多种风味,你可以在官方文档中找到。
通过生成的静态 Routines.getCustomer() 可以更紧凑地获取相同的 Result<Record>:
Result<Record> result = getCustomer(
ctx.configuration(), 120000);
如果你需要一个 Table 而不是这个 Result<Record>,那么简单地依赖于 org.jooq.impl.DSL.table,就像以下两个示例中那样:
Table<?> t = table(result);
Table<CustomerRecord> t = table(result.into(CUSTOMER));
接下来,你可以在查询中使用 t,就像使用任何常规的 Table 一样:
ctx.select(CUSTOMERDETAIL.ADDRESS_LINE_FIRST,
CUSTOMERDETAIL.POSTAL_CODE,
t.field(name("CUSTOMER_NAME")))
.from(t)
.join(CUSTOMERDETAIL)
.on(CUSTOMERDETAIL.CUSTOMER_NUMBER.eq(
t.field(name("CUSTOMER_NUMBER"), Long.class)))
.fetch();
另一方面,Routines.getCustomer() 的另一种风味返回的结果被一个 Field 包装,作为 Field<Result<Record>>。这允许我们使用这个结果作为 Field。例如,这里有一个 SELECT:
ctx.select(getCustomer(field(
select(avg(CUSTOMER.CREDIT_LIMIT))
.from(CUSTOMER)))).fetch();
你可以在 Oracle 的 CursorFunction 中练习这些示例。
那么一个返回多个游标的函数呢?这里是一个为 PostgreSQL 编写的示例:
CREATE OR REPLACE FUNCTION "get_offices_multiple"()
RETURNS SETOF REFCURSOR
AS $$
DECLARE
"ref1" REFCURSOR;
"ref2" REFCURSOR;
BEGIN
OPEN "ref1" FOR
SELECT "public"."office"."city", "public"."office"."country"
FROM "public"."office"
WHERE "public"."office"."internal_budget" < 100000;
RETURN NEXT "ref1";
OPEN "ref2" FOR
SELECT "public"."office"."city", "public"."office"."country"
FROM "public"."office"
WHERE "public"."office"."internal_budget" > 100000;
RETURN NEXT "ref2";
END;
$$ LANGUAGE plpgsql;
在这种情况下,每个游标产生一个 Result<Record>,被一个生成的 Record 类包装。这里,我们有两个游标,因此有两个 Result<Record> 被两个生成的 GetOfficesMultipleRecord 实例包装。当我们调用 Routines.getOfficesMultiple(Configuration c) 时,我们得到一个 Result<GetOfficesMultipleRecord>,它可以如下展开:
Result<GetOfficesMultipleRecord> results =
getOfficesMultiple(ctx.configuration());
for (GetOfficesMultipleRecord result : results) {
Result<Record> records = result.getGetOfficesMultiple();
System.out.println("-------------------------");
for (Record r : records) {
System.out.println(r.get("city") + ", " + r.get("country"));
}
}
你可以在 PostgreSQL 的 CursorFunction 中练习这些示例。
表值函数
除了数据库视图之外,SQL 中被低估的功能之一是表值函数。这本书中我们已经讨论过这个特性不止一次了,但这次,让我们添加一些更多细节。所以,表值函数是一个返回一组数据作为表数据类型的函数。返回的表可以像常规表一样使用。
表值函数在 MySQL 中不受支持,但在 PostgreSQL、Oracle 和 SQL Server 中受支持。下面是一个 PostgreSQL 表值函数的代码片段(注意 RETURNS TABLE 语法,它表示函数的 SELECT 查询返回的数据作为表给调用函数的任何东西):
CREATE OR REPLACE FUNCTION "product_of_product_line"(
IN "p_line_in" VARCHAR)
RETURNS TABLE("p_id" BIGINT, "p_name" VARCHAR,
"p_line" VARCHAR) LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT ...;
END;
$$;
默认情况下,jOOQ 代码生成器将为该函数生成一个名为ProductOfProductLine的类,位于jooq.generated.tables包中,而不是jooq.generated.routines包中。解释很简单;jOOQ(像大多数数据库一样)将表值函数视为普通表,可以在SELECT语句的FROM子句中使用,就像任何其他表一样。Oracle 是一个例外,在那里将它们视为独立的程序——在这个上下文中,jOOQ 有一个标志设置,允许我们指示表值函数应该被视为普通表(在jooq.generated.tables中生成)还是作为普通程序(在jooq.generated.routines中生成)。这详细说明在第六章,处理不同类型的 JOIN 语句。
通过call()方法调用表值函数(带有参数):
ProductOfProductLine popl = new ProductOfProductLine();
Table<ProductOfProductLineRecord> t = popl.call("Trains");
Result<ProductOfProductLineRecord> r =
ctx.fetch(popl.call("Trains"));
Result<ProductOfProductLineRecord> r =
ctx.selectFrom(popl.call("Trains")).fetch();
在查询中,我们可能更喜欢使用 jOOQ 生成器在ProductOfProductLine中生成的PRODUCT_OF_PRODUCT_LINE静态字段。以下两个示例生成相同的 SQL:
ctx.selectFrom(PRODUCT_OF_PRODUCT_LINE.call("Trains"))
.fetch();
ctx.selectFrom(PRODUCT_OF_PRODUCT_LINE(val("Trains")))
.fetch();
这里有两个在FROM子句中调用此表值函数的示例:
ctx.selectFrom(PRODUCT_OF_PRODUCT_LINE.call("Trains"))
.where(PRODUCT_OF_PRODUCT_LINE.P_NAME.like("1962%"))
.fetch();
ctx.select(PRODUCT_OF_PRODUCT_LINE.P_ID,
PRODUCT_OF_PRODUCT_LINE.P_NAME)
.from(PRODUCT_OF_PRODUCT_LINE.call("Classic Cars"))
.where(PRODUCT_OF_PRODUCT_LINE.P_ID.gt(100L))
.fetch();
由于表值函数返回一个表,我们应该能够在连接中使用它们。但是常规的JOIN功能不允许我们将表值函数与表连接,因此我们需要另一种方法。这就是CROSS APPLY和OUTER APPLY(或LATERAL)出现的地方。在第六章,处理不同类型的 JOIN 语句中,你看到了使用CROSS/OUTER APPLY来解决基于 TOP-N 查询结果连接两个表的流行任务的示例。因此,CROSS/OUTER APPLY允许我们在查询中结合表值函数返回的结果与其他表的结果,简而言之,就是将表值函数与其他表连接。
例如,让我们使用CROSS/OUTER APPLY(你可以将其视为 Java 中的Stream.flatMap())将PRODUCTLINE表与我们的表值函数连接起来。假设我们已经添加了一个没有产品的名为直升机的新PRODUCTLINE,让我们看看CROSS APPLY是如何工作的:
ctx.select(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE
.TEXT_DESCRIPTION, PRODUCT_OF_PRODUCT_LINE.asterisk())
.from(PRODUCTLINE)
.crossApply(PRODUCT_OF_PRODUCT_LINE(
PRODUCTLINE.PRODUCT_LINE))
.fetch();
由于直升机产品线没有产品,CROSS APPLY不会检索它,因为CROSS APPLY的作用类似于CROSS JOIN LATERAL。那么OUTER APPLY呢?
ctx.select(PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE
.TEXT_DESCRIPTION, PRODUCT_OF_PRODUCT_LINE.asterisk())
.from(PRODUCTLINE)
.outerApply(PRODUCT_OF_PRODUCT_LINE(
PRODUCTLINE.PRODUCT_LINE))
.fetch();
另一方面,OUTER APPLY的作用类似于LEFT OUTER JOIN LATERAL,因此直升机产品线也会被返回。
Lukas Eder 在这里分享了一个观点:“实际上,出于历史原因,在 Db2、Oracle 和 PostgreSQL 中,在某些条件下,APPLY 或 LATERAL 是可选的。SQL Server 长期以来都有 APPLY,但其他数据库最近才引入 LATERAL。我个人不明白明确 LATERAL 的价值。隐式 LATERAL 的含义总是很清楚...”
你可以在捆绑的代码中查看这些示例,TableValuedFunction适用于 PostgreSQL、Oracle 和 SQL Server。
Oracle 的包
Oracle 允许我们将逻辑上常见相关的函数/过程组合成一个包。包有两个部分:第一部分包含公共项,称为 包规范,而第二部分,称为 包主体,提供了在包规范中声明的游标或子程序的代码。如果在包规范中没有声明游标/子程序,则可以跳过包主体。如果您不是 Oracle 的粉丝,那么以下语法可能有助于您更容易地消化这个主题:

图 15.2 – Oracle 包的语法
卢卡斯·埃德分享了一个有助于更好地理解这个主题的类比:"如果有助于理解,包规范就像接口,而主体就像单例实例,有点像。在某种程度上,就像这样:" twitter.com/lukaseder/status/1443855980693962755。
包通过隐藏包体内的实现细节来维持模块化,促进清晰的设计,并提高代码的可维护性。此外,包在第一次调用函数/过程时作为一个整体加载到内存中,因此从该包中后续调用函数/过程不需要磁盘 I/O。虽然有关使用 Oracle 包的更多信息可在官方文档中找到,但让我们举一个例子:
CREATE OR REPLACE PACKAGE "DEPARTMENT_PKG" AS
TYPE "BGT" IS RECORD ("LOCAL_BUDGET" FLOAT, "CASH" FLOAT);
FUNCTION "GET_BGT"("p_profit" IN FLOAT)
RETURN "BGT";
FUNCTION "GET_MAX_CASH"
RETURN FLOAT;
END "DEPARTMENT_PKG";
/
CREATE OR REPLACE PACKAGE BODY "DEPARTMENT_PKG"
-- check bundled code for this skipped part
END"DEPARTMENT_PKG";
/
卢卡斯·埃德(Lukas Eder)的一个辛辣的小贴士:"/* 是 SQLPlus 的 'spool' 令牌(也由 SQL Developer 支持),而不是实际的 PL/SQL 语法元素。例如,它在 DBeaver 中不起作用。)*
另外,关于引号标识符的一个小贴士:可能是一个很好的提醒,为了与非 jOOQ 代码更好地互操作,可能不使用引号标识符会更好。如果所有调用者都必须始终引用该标识符,而不是使用 jOOQ,那将会很麻烦:)
因此,我们有一个名为 DEPARTMENT_PKG 的包,其中包含一个名为 BGT 的用户定义类型和两个函数,GET_BGT() 和 GET_MAX_CASH()。在源代码生成阶段,jOOQ 将通过以下 Java 子包反映此包及其内容:
-
jooq.generated.packages– 包含代表包并公开DEPARTMENT_PKG静态的DepartmentPkg类,可以用来调用函数,如DEPARTMENT_PKG.getMaxCash()和DEPARTMENT_PKG.getBgt(),并将结果作为Field获取。 -
jooq.generated.packages.department_pkg– 包含代表包中两个函数的GetBgt和GetMaxCash类。此外,它还包含一个UDTs类,其中包含静态BGT,用于CLASSICMODELS.DEPARTMENT_PKG.BGT(jooq.generated.packages.department_pkg.udt.Bgt.BGT)。 -
jooq.generated.packages.department_pkg.udt– 包含将BGTUDT 类型映射为UDTImpl扩展的Bgt类。 -
jooq.generated.packages.department_pkg.udt.records– 包含代表BGTUDT 类型作为UDTRecordImpl扩展的BgtRecord类。
调用这两个函数(GET_BGT() 和 GET_MAX_CASH())可以通过实例化 GetMaxCash 类,分别 GetBgt 类,并按如下方式调用 execute():
GetMaxCash gmc = new GetMaxCash();
gmc.execute(ctx.configuration());
double resultGmc = gmc.getReturnValue();
GetBgt bgt = new GetBgt();
bgt.setPProfit(50000.0);
bgt.execute(ctx.configuration());
BgtRecord resultBgt = bgt.getReturnValue();
我们也可以通过静态方法 DepartmentPkg.getBgt() 和 DepartmentPkg.getMaxCash() 来压缩这些示例,如下所示:
double resultGmc = getMaxCash(ctx.configuration());
BgtRecord resultBgt = getBgt(ctx.configuration(), 50000.0);
从查询中调用这些函数也是可能的。例如,以下是在 SELECT 中调用它们的两个简单示例:
ctx.select(getMaxCash()).fetch();
double mc = ctx.fetchValue(getMaxCash());
The rendered SQL:
SELECT "CLASSICMODELS"."department_pkg"."get_max_cash"()
FROM DUAL
这里还有一个示例,它使用了同一个查询中的两个函数:
ctx.select(OFFICE.OFFICE_CODE, OFFICE.CITY, OFFICE.COUNTRY,
DEPARTMENT.NAME, DEPARTMENT.LOCAL_BUDGET)
.from(OFFICE)
.join(DEPARTMENT)
.on(OFFICE.OFFICE_CODE.eq(DEPARTMENT.OFFICE_CODE)
.and(DEPARTMENT.LOCAL_BUDGET
.in(getBgt(ctx.configuration(),
getMaxCash(ctx.configuration()))
.getLocalBudget())))
.fetch();
查看以下示例,这些示例位于 Package for Oracle 的旁边,而这里没有展示的其他几个示例。
Oracle 的成员函数/过程
主要来说,Oracle PL/SQL 对象类型包含属性和成员(或方法)。属性或字段具有数据类型,它们用于存储数据,而成员是定义在对象类型中的子程序(函数/过程),用于操作属性以实现某些功能。这样,Oracle UDTs 是实现对象关系数据库管理系统(ORDBMS)的全面尝试。PostgreSQL 并没有像 Oracle 那样走得更远。
如果你不是 Oracle 的粉丝,那么以下语法应该能对这个主题有所帮助:


图 15.3 – Oracle 成员语法
基于这个语法,让我们举一个例子。我们的 MANAGER 表有一个名为 MANAGER_EVALUATION 的字段,其类型为 EVALUATION_CRITERIA(在 jOOQ 中为 TableField<ManagerRecord, EvaluationCriteriaRecord> 类型),定义如下:
CREATE OR REPLACE TYPE "EVALUATION_CRITERIA" AS OBJECT (
"communication_ability" NUMBER(7),
"ethics" NUMBER(7),
"performance" NUMBER(7),
"employee_input" NUMBER(7),
MEMBER FUNCTION "IMPROVE"("k" NUMBER)
RETURN "EVALUATION_CRITERIA",
MAP MEMBER FUNCTION "SCORE" RETURN NUMBER
);
CREATE OR REPLACE TYPE BODY "EVALUATION_CRITERIA" AS
-- check bundled code for this skipped part
END;
在这里,我们有一个包含四个属性(communication_ability、ethics、performance 和 employee_input)、两个成员函数(IMPROVE() 和 SCORE())以及没有成员过程的对象类型。
jOOQ 代码生成器生成以下工件:
-
jooq.generated.udt– 在这个包中,我们有一个名为EvaluationCriteria的 UDT 类型,代表 jOOQ 的UDTImpl<EvaluationCriteriaRecord>的扩展。它包含静态EVALUATION_CRITERIA、COMMUNICATION_ABILITY、ETHICS、PERFORMANCE和EMPLOYEE_INPUT,用于引用这些 UDT 属性,以及几种improve()和score()成员函数的变体,这些函数返回纯结果或包装在Field中。 -
jooq.generated.udt.records– 包含代表 UDT 记录作为 jOOQ 的UDTRecordImpl<EvaluationCriteriaRecord>扩展的EvaluationCriteriaRecord。EvaluationCriteriaRecord包含对象类型属性的获取器/设置器,并包含improve()和score()方法。这些方法封装了调用实际的improve()和score()成员函数所需的代码。 -
jooq.generated.udt.evaluation_criteria– 包含Improve和Score类(例程),因此每个成员函数都有一个类。EvaluationCriteria和EvaluationCriteriaRecord都在内部使用这些类。
因此,我们可以区分从空记录或现有记录(例如,从数据库中检索的记录)开始调用成员函数。从空记录开始的传统方法依赖于 DSLContext.newRecord():
EvaluationCriteriaRecord ecr =
ctx.newRecord(EVALUATION_CRITERIA);
创建的记录已经附加。或者,我们可以实例化 EvaluationCriteriaRecord 或使用 EVALUATION_CRITERIA.newRecord(),但生成的记录不会附加到配置(数据库)上,因此您必须显式调用 attach() 来附加它。
接下来,我们设置属性的值并调用成员函数。在这里,我们调用 score() 方法,它返回一个 BigDecimal:
ecr.setCommunicationAbility(58);
ecr.setEthics(30);
ecr.setPerformance(26);
ecr.setEmployeeInput(59);
BigDecimal result = ecr.score();
另一方面,improve() 方法通过给定的值增加评估属性,并返回一个新的 EvaluationCriteriaRecord:
EvaluationCriteriaRecord newEcr = ecr.improve(10);
newEcr 记录的 communication_ability 为 68 而不是 58,ethics 为 40 而不是 30,performance 为 36 而不是 26。只有 employee_input 保持不变。
我们也可以在查询中使用 ecr/newEcr。以下是一个使用 newEcr 的示例,它从一个空的记录开始,紧邻 MANAGER.MANAGER_EVALUATION,该记录是从数据库中检索到的(请记住,MANAGER.MANAGER_EVALUATION 是一个 TableField<ManagerRecord, EvaluationCriteriaRecord>):
ctx.select(MANAGER.MANAGER_ID, MANAGER.MANAGER_NAME)
.from(MANAGER)
.where(score(MANAGER.MANAGER_EVALUATION)
.lt(newEcr.score()))
.fetch();
这里还有一个结合 improve() 和 score() 调用的示例:
ctx.select(MANAGER.MANAGER_ID, MANAGER.MANAGER_NAME)
.from(MANAGER)
.where(score(improve(
MANAGER.MANAGER_EVALUATION, inline(10)))
.gt(BigDecimal.valueOf(57)))
.fetch();
检查这里与省略的 MemberFunction 中的其他示例。
用户定义的聚合存储函数
Oracle 和 SQL Server 允许我们定义聚合存储函数(如果您不熟悉这个主题,请在 Google 上搜索:Oracle 用户定义聚合函数接口 和 SQL Server 用户定义聚合函数)。
此外,这些函数的代码相当长,无法在此列出,请检查捆绑的代码。对于 Oracle,请检查应用程序 UserDefinedAggFunction,它调用一个名为 secondMax() 的用户定义聚合函数,该函数找到第二个最大值:
ctx.select(secondMax(ORDERDETAIL.QUANTITY_ORDERED),
ORDERDETAIL.PRODUCT_ID)
.from(ORDERDETAIL)
.groupBy(ORDERDETAIL.PRODUCT_ID)
.having(secondMax(ORDERDETAIL.QUANTITY_ORDERED)
.gt(BigDecimal.valueOf(55)))
.fetch();
对于 SQL Server,请检查也命名为 UserDefinedAggFunction 的应用程序。在这里,我们调用一个名为 concatenate() 的聚合存储函数,该函数简单地连接给定的字符串:
ctx.select(concatenate(EMPLOYEE.FIRST_NAME))
.from(EMPLOYEE)
.where(EMPLOYEE.FIRST_NAME.like("M%"))
.fetch();
为了工作,请注意您需要将 StringUtilities.dll DLL 文件(在捆绑的代码中可用)放置在函数代码中指定的路径上。
存储过程
jOOQ 允许您通过相同的 Routines API 调用存储过程。接下来,让我们看看调用不同类型存储过程的几个示例。
存储过程和输出参数
例如,让我们考虑以下用 Oracle 表达的存储过程,它有一个 OUT 参数:
CREATE OR REPLACE NONEDITIONABLE PROCEDURE
"GET_AVG_PRICE_BY_PRODUCT_LINE" (
"pl" IN VARCHAR2, "average" OUT DECIMAL) AS
BEGIN
SELECT AVG("PRODUCT"."BUY_PRICE")
INTO "average"
FROM "PRODUCT"
WHERE "PRODUCT"."PRODUCT_LINE" = "pl";
END;
jOOQ 将此存储过程的 Java 版本生成为一个名为GetAvgPriceByProductLine的类,位于jooq.generated.routines包中。这个类的各种方法允许我们准备参数(每个输入参数都有一个 setter,每个输出参数都有一个 getter),并按如下方式调用我们的存储过程:
GetAvgPriceByProductLine avg = new GetAvgPriceByProductLine();
avg.setPl("Classic Cars");
avg.execute(ctx.configuration());
BigInteger result = avg.getAverage();
我们可以通过生成的jooq.generated.Routines.getAvgPriceByProductLine()静态方法更简洁地表达如下:
BigInteger result = getAvgPriceByProductLine(
ctx.configuration(), "Classic Cars");
通过getAvgPriceByProductLine(Configuration configuration, String pl)风味在 jOOQ 查询中调用存储过程,如下例所示:
ctx.select(PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_NAME,
PRODUCT.BUY_PRICE)
.from(PRODUCT)
.where(PRODUCT.BUY_PRICE.coerce(BigInteger.class)
.gt(getAvgPriceByProductLine(
ctx.configuration(), "Classic Cars"))
.and(PRODUCT.PRODUCT_LINE.eq("Classic Cars")))
.fetch();
jOOQ 首先渲染存储过程的调用并获取OUT参数的值:
call "CLASSICMODELS"."get_avg_price_by_product_line" (
'Classic Cars', ?)
接下来,获取的值(在本例中为64)用于渲染SELECT:
SELECT "CLASSICMODELS"."PRODUCT"."PRODUCT_ID",
"CLASSICMODELS"."PRODUCT"."PRODUCT_NAME",
"CLASSICMODELS"."PRODUCT"."BUY_PRICE"
FROM "CLASSICMODELS"."PRODUCT"
WHERE ("CLASSICMODELS"."PRODUCT"."BUY_PRICE" > 64
AND "CLASSICMODELS"."PRODUCT"."PRODUCT_LINE"
= 'Classic Cars')
接下来,让我们调用一个不带输出参数的存储过程,该存储过程通过SELECT语句获取单个结果集。
获取单个结果集的存储过程
下面是一个 MySQL 示例,展示了不包含输出参数的存储过程,通过SELECT语句获取单个结果集:
DELIMITER $$
CREATE PROCEDURE `get_product`(IN `pid` BIGINT)
BEGIN
SELECT * FROM `product` WHERE `product`.`product_id` = `pid`;
END $$
DELIMITER
通过生成的GetProduct类从 jOOQ 调用此存储过程:
GetProduct gp = new GetProduct();
gp.setPid(1L);
gp.execute(ctx.configuration());
Result<Record> result = gp.getResults().get(0);
结果通过gp.getResults()获得。由于只有一个结果(由SELECT产生的结果集),我们必须调用get(0)。如果有两个结果(例如,如果存储过程中有两个SELECT语句),那么我们将调用get(0)以获取第一个结果,调用get(1)以获取第二个结果。或者,在更多结果的情况下,简单地循环结果。请注意,在存储过程中,getReturnValue()方法返回void,因为存储过程不作为存储函数返回结果(它们不包含RETURN语句)。实际上,SQL Server 的存储过程可以返回一个错误代码,这是一个int。您可以在为存储过程生成的 SQL Server 代码中看到这一点。
通过Routines.getProduct()调用之前的存储过程也返回void:
getProduct(ctx.configuration(), 1L);
通过getResults()获得的结果类型为Result<Record>。可以很容易地将其转换为常规的Table,如下所示:
Table<?> t = table(gp.getResults().get(0));
Table<ProductRecord> t = table(gp.getResults()
.get(0).into(PRODUCT));
接下来,让我们将get_product()存储过程用 Oracle 表达,并添加一个类型为SYS_REFCURSOR的OUT参数。
带有一个游标的存储过程
以下存储过程获取与之前的get_product()相同的结果集,但它通过游标返回:
CREATE OR REPLACE NONEDITIONABLE PROCEDURE "GET_PRODUCT"(
"pid" IN NUMBER, "cursor_result" OUT SYS_REFCURSOR) AS
BEGIN
OPEN "cursor_result" FOR
SELECT * FROM "PRODUCT"
WHERE "PRODUCT"."PRODUCT_ID" = "pid";
END;
这次,在GetProduct中,jOOQ 为名为getCursorResult()的OUT参数生成了一个 getter,这使得我们能够以Result<Record>的形式获取结果:
GetProduct gp = new GetProduct();
gp.setPid(1L);
gp.execute(ctx.configuration());
Result<Record> result = gp.getCursorResult();
或者,您可以通过Routines.getProduct(Configuration configuration, Number pid)更简洁地获取它:
Result<Record> result = getProduct(ctx.configuration(), 1L);
如同往常,这个Result<Record>可以很容易地转换为常规的Table:
Table<?> t = table(gp.getResults().get(0));
Table<?> t = table(getProduct(ctx.configuration(), 1L));
Table<ProductRecord> t =
table(gp.getCursorResult().into(PRODUCT));
Table<ProductRecord> t =
table(getProduct(ctx.configuration(), 1L)
.into(PRODUCT));
接下来,您可以在查询中使用这个Table。
获取多个结果集的存储过程
进一步,让我们处理一个返回多个结果集的存储过程。这里以 MySQL 为例:
DELIMITER $$
CREATE PROCEDURE `get_emps_in_office`(
IN `in_office_code` VARCHAR(10))
BEGIN
SELECT `office`.`city`, `office`.`country`,
`office`.`internal_budget`
FROM `office`
WHERE `office`.`office_code`=`in_office_code`;
SELECT `employee`.`employee_number`,
`employee`.`first_name`, `employee`.`last_name`
FROM `employee`
WHERE `employee`.`office_code`=`in_office_code`;
END $$
DELIMITER ;
正如你所知,jOOQ 会生成 GetEmpsInOffice 类,并且结果集可以通过 getResults() 获取:
GetEmpsInOffice geio = new GetEmpsInOffice();
geio.setInOfficeCode("1");
geio.execute(ctx.configuration());
Results results = geio.getResults();
for (Result<Record> result : results) {
System.out.println("Result set:\n");
for (Record record : result) {
System.out.println(record);
}
}
Routines.getEmpsInOffice(Configuration c, String inOfficeCode) 返回 void。
多游标存储过程
接下来,让我们以 get_emps_in_office() 存储过程为例,并在 Oracle 中通过添加两个类型为 SYS_REFCURSOR 的 OUT 参数来表示它:
CREATE OR REPLACE NONEDITIONABLE PROCEDURE
"GET_EMPS_IN_OFFICE"("in_office_code" IN VARCHAR,
"cursor_office" OUT SYS_REFCURSOR,
"cursor_employee" OUT SYS_REFCURSOR) AS
BEGIN
OPEN "cursor_office" FOR
SELECT "OFFICE"."CITY", "OFFICE"."COUNTRY",
"OFFICE"."INTERNAL_BUDGET"
FROM "OFFICE"
WHERE "OFFICE"."OFFICE_CODE" = "in_office_code";
OPEN "cursor_employee" FOR
SELECT "EMPLOYEE"."EMPLOYEE_NUMBER",
"EMPLOYEE"."FIRST_NAME", "EMPLOYEE"."LAST_NAME"
FROM "EMPLOYEE"
WHERE "EMPLOYEE"."OFFICE_CODE" = "in_office_code";
END;
这次,除了你已熟悉的 getResults() 之外,我们还可以利用 jOOQ 为 OUT 参数生成的获取器,如下所示:
GetEmpsInOffice geio = new GetEmpsInOffice();
geio.setInOfficeCode("1");
geio.execute(ctx.configuration());
Result<Record> co = geio.getCursorOffice();
Result<Record> ce = geio.getCursorEmployee();
此外,依赖 Routines.getEmpsInOffice(Configuration c, String inOfficeCode) 非常方便:
GetEmpsInOffice results =
getEmpsInOffice(ctx.configuration(), "1");
接下来,你可以分别依赖 results.getCursorInfo()、results.getCursorEmployee() 或通过以下方式循环结果:
for (Result<Record> result : results.getResults()) {
…
}
接下来,循环每个 Result<Record> 作为 for (Record record : result) …。
不确定是否值得提及,但至少 Oracle 也知道有类型的 REF CURSORS(而不是仅仅 SYS_REFCURSOR),jOOQ 也将很快支持这些功能(当你阅读这本书时,这些功能应该已经可用):github.com/jOOQ/jOOQ/issues/11708。
通过 CALL 语句调用存储过程
最后,让我们解决通过匿名过程块中的 CALL 语句和直接 CALL 调用存储过程的 API。考虑以下用 Oracle 表达的存储过程(完整的代码可在捆绑的代码中找到):
CREATE OR REPLACE NONEDITIONABLE PROCEDURE
"REFRESH_TOP3_PRODUCT"("p_line_in" IN VARCHAR2) AS
BEGIN
DELETE FROM "TOP3PRODUCT";
INSERT INTO ...
FETCH NEXT 3 ROWS ONLY;
END;
通过在匿名过程块中通过 DSLContext.begin() 调用 CALL 语句来调用此存储过程可以这样做:
ctx.begin(call(name("REFRESH_TOP3_PRODUCT"))
.args(val("Trains")))
.execute();
或者,直接通过 DSLContext.call() 调用:
ctx.call(name("REFRESH_TOP3_PRODUCT"))
.args(val("Trains"))
.execute();
你可以在 CallProcedure 中练习所有这些示例。
jOOQ 和创建存储函数/过程
从版本 3.15 开始,jOOQ 开始添加用于创建存储函数、过程和触发器的 API。其中包括对 CREATE FUNCTION、CREATE OR REPLACE FUNCTION、CREATE PROCEDURE、CREATE OR REPLACE PROCEDURE、DROP FUNCTION 和 DROP PROCEDURE 的支持。
创建存储函数
例如,创建 MySQL 的标量函数可以这样做:
Parameter<Integer> quantity = in("quantity", INTEGER);
Parameter<Double> listPrice = in("list_price", DOUBLE);
Parameter<Double> fractionOfPrice =
in("fraction_of_price", DOUBLE);
ctx.createOrReplaceFunction("sale_price_jooq")
.parameters(quantity, listPrice, fractionOfPrice)
.returns(DECIMAL(10, 2))
.deterministic()
.as(return_(listPrice.minus(listPrice
.mul(fractionOfPrice)).mul(quantity)))
.execute();
在这里,我们创建了一个具有三个输入参数的标量函数,这些参数通过直观的 Parameter API 创建,指定了它们的名称和类型。对于 MySQL,jOOQ 生成以下代码:
DROP FUNCTION IF EXISTS `sale_price_jooq`;
CREATE FUNCTION `sale_price_jooq`(`quantity` INT,
`list_price` DOUBLE, `fraction_of_price` DOUBLE)
RETURNS DECIMAL(10, 2)
DETERMINISTIC
BEGIN
RETURN ((`list_price` - (`list_price` *
`fraction_of_price`)) * `quantity`);
END;
注意,为了使其工作,你应该注意以下注意事项。
重要注意事项
在 MySQL 中,如果我们打开 allowMultiQueries 标志(默认为 false),就可以执行语句批处理;否则,我们会得到一个错误。之前生成的 SQL 链接了两个语句,因此需要打开此标志——我们可以通过 JDBC URL 作为 jdbc:mysql:…/classicmodels?allowMultiQueries=true 来实现。或者,在这种情况下,我们可以依靠 dropFunctionIfExists() 和 createFunction() 组合而不是 createOrReplaceFunction()。我强烈建议你花几分钟时间阅读 Lukas Eder 的这篇文章,它详细解释了此标志在 jOOQ 上下文中的含义:blog.jooq.org/mysqls-allowmultiqueries-flag-with-jdbc-and-jooq/。
你已经知道如何通过生成的代码从 jOOQ 调用此函数。这意味着你必须运行此代码来在数据库中创建存储函数,然后运行 jOOQ 代码生成器以获取预期的 jOOQ 元数据。另一方面,你可以通过 DSL 的 function() 以普通 SQL 的方式调用它,如下例所示:
float result = ctx.select(function(name("sale_price_jooq"),
DECIMAL(10, 2), inline(10), inline(20.45), inline(0.33)))
.fetchOneInto(Float.class);
你可以在 CreateFunction 中练习这个示例。
关于创建以下具有输出参数的 PostgreSQL 函数呢?
CREATE OR REPLACE FUNCTION "swap_jooq"(
INOUT "x" INT, INOUT "y" INT)
RETURNS RECORD LANGUAGE PLPGSQL AS $$
BEGIN
SELECT "x", "y" INTO "y", "x";
END; $$
从 jOOQ,此函数可以创建如下:
Parameter<Integer> x = inOut("x", INTEGER);
Parameter<Integer> y = inOut("y", INTEGER);
ctx.createOrReplaceFunction("swap_jooq")
.parameters(x, y)
.returns(RECORD)
.as(begin(select(x, y).into(y, x)))
.execute();
可以通过如下示例中的普通 SQL 调用此函数:
Record1<Record> result = ctx.select(
function(name("swap_jooq"),
RECORD, inline(1), inline(2))).fetchOne();
你可以在 CreateFunction for PostgreSQL 中练习这个示例旁边另一个使用 OUT 参数的示例。为了更详细地探索此 API 的更多示例,请考虑捆绑的代码和 jOOQ 手册。
创建存储过程
让我们从用 SQL Server 方言表示的存储过程开始:
CREATE OR ALTER PROCEDURE [update_msrp_jooq]
@product_id BIGINT, @debit INT AS
BEGIN
UPDATE [classicmodels].[dbo].[product]
SET [classicmodels].[dbo].[product].[msrp] =
([classicmodels].[dbo].[product].[msrp] - @debit)
WHERE [classicmodels].[dbo].[product].[product_id] =
@product_id;
END;
这个有两个输入参数并更新 PRODUCT.MSRP 字段的存储过程可以通过 jOOQ API 如下创建:
Parameter<Long> id = in("id", BIGINT);
Parameter<Integer> debit = in("debit", INTEGER);
ctx.createOrReplaceProcedure("update_msrp_jooq")
.parameters(id, debit)
.as(update(PRODUCT)
.set(PRODUCT.MSRP, PRODUCT.MSRP.minus(debit))
.where(PRODUCT.PRODUCT_ID.eq(id)))
.execute();
你已经知道如何通过生成的代码从 jOOQ 调用此过程,所以这次,让我们通过 CALL 语句来调用它:
// CALL statement in an anonymous procedural block
var result = ctx.begin(call(name("update_msrp_jooq"))
.args(inline(1L), inline(100)))
.execute();
// CALL statement directly
var result = ctx.call(name("update_msrp_jooq"))
.args(inline(1L), inline(100))
.execute();
返回的结果表示此 UPDATE 影响的行数。此示例在 CreateProcedure for SQL Server 和 PostgreSQL 中可用。
接下来,让我们选择一个用 Oracle 方言表示的示例:
CREATE OR REPLACE NONEDITIONABLE PROCEDURE
"get_avg_price_by_product_line_jooq" (
"pl" IN VARCHAR2,"average" OUT DECIMAL) AS
BEGIN
SELECT AVG("CLASSICMODELS"."PRODUCT"."BUY_PRICE")
INTO "average" FROM "CLASSICMODELS"."PRODUCT"
WHERE "CLASSICMODELS"."PRODUCT"."PRODUCT_LINE" = "pl";
END;
这次,创建此过程的 jOOQ 代码如下:
Parameter<String> pl = in("pl", VARCHAR);
Parameter<BigDecimal> average = out("average", DECIMAL);
ctx.createOrReplaceProcedure(
"get_avg_price_by_product_line_jooq")
.parameters(pl, average)
.as(select(avg(PRODUCT.BUY_PRICE)).into(average)
.from(PRODUCT)
.where(PRODUCT.PRODUCT_LINE.eq(pl)))
.execute();
由于你已经从上一节“存储过程和输出参数”熟悉了这个存储过程,因此调用它应该没有问题。示例在 CreateProcedure for Oracle 中可用。
最后,让我们解决一个通过 SELECT 获取结果集的 MySQL 存储过程:
CREATE PROCEDURE `get_office_gt_budget_jooq`(`budget` INT)
BEGIN
SELECT `classicmodels`.`office`.`city`,
`classicmodels`.`office`.`country`,
`classicmodels`.`office`.`state`
FROM `classicmodels`.`office`
WHERE `classicmodels`.`office`.`internal_budget` > `budget`;
END;
创建存储过程的 jOOQ 代码如下:
Parameter<Integer> budget = in("budget", INTEGER);
ctx.createOrReplaceProcedure("get_office_gt_budget_jooq")
.parameters(budget)
.as(select(OFFICE.CITY, OFFICE.COUNTRY, OFFICE.STATE)
.from(OFFICE)
.where(OFFICE.INTERNAL_BUDGET.gt(budget)))
.execute();
为了使此代码正常工作,我们需要打开 allowMultiQueries 标志,正如上一节“创建存储函数”中所述。
你可以在名为 CreateProcedure for MySQL 的应用程序中找到这个示例旁边另一个获取两个结果集的示例。
摘要
在本章中,你已经学会了如何调用和创建一些典型的存储函数和过程。由于这些是强大的 SQL 工具,jOOQ 致力于提供一个全面的 API,以涵盖在不同方言中表达这些工具的各种可能性。很可能会在你阅读这本书的时候,jOOQ 已经进一步丰富了这一 API,并且捆绑代码中将提供更多示例。
在下一章中,我们将探讨别名和 SQL 模板。
第十六章:处理别名和 SQL 模板化
本章涵盖了两个重要的主题,这些主题将帮助您成为 jOOQ 高级用户:别名和 SQL 模板化。
本章的第一部分探讨了通过 jOOQ DSL 对表和列进行别名的几种实践。这部分的目标是在您需要通过 jOOQ 表达 SQL 别名时让您感到舒适,并为您提供一份全面的示例列表,涵盖最常见的用例。
本章的第二部分全部关于 SQL 模板化,或者说是当 jOOQ DSL 无法帮助我们时如何表达 SQL。在极少数情况下,您将不得不编写纯 SQL 或结合 DSL 和纯 SQL 来获取一些边缘情况或特定供应商的功能。
在本章中,我们将涵盖以下主要内容:
-
在 jOOQ 中表达 SQL 别名
-
SQL 模板化
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter16。
在 jOOQ 中表达 SQL 别名
SQL 别名化是一个简单的任务。毕竟,这仅仅是给你的列和表起一些昵称,并通过这些昵称来引用它们,而不是使用它们的真实名称。但尽管这看起来可能很简单,这实际上是一个相当有争议的话题。您可能会遇到的一些开放性问题可能听起来像这样:我只有在必要时才使用别名吗(例如,当我需要两次引用同一张表时)?我应该使用有意义的名称,还是单个字母就能行得通(如p、q、t1、t2等)?它们是否会提高可读性并减少输入时间?最可能的正确答案是这取决于……上下文、查询、谁在编写查询(开发者、DBA、生成器)等等!
如您很快就会看到的,通过 DSL 进行别名化需要我们遵守一些规则,并准备好应对一些冗长,因为宿主语言(在这里是 Java)存在一些 DSL 必须尽可能优雅地解决的缺点。别名化与派生表、算术表达式和类型转换并列,是 DSL 面临的主要挑战之一,因此让我们看看我们究竟需要了解哪些内容。
下面的部分中的示例可以通过SimpleAliases和AliasesSamples获取。
表达简单的别名表和列
无论您喜欢如何使用 SQL 别名,当您想在 jOOQ 中表达它们时,您必须了解几个方法,包括as()和asTable(),它们有多种风味,如as(String alias)、as(Name alias)、as(Name as, Name... fieldAliases)、asTable()、asTable(String alias)、asTable(Name alias)、asTable(Table<?> alias)等等。通常,我们必须处理别名表和字段。以下是一个使用 jOOQ 中别名表的快速示例:
ctx.select(field(name("t", "first_name")),
field(name("t", "last_name")))
.from(EMPLOYEE.as("t"))
.fetch();
ctx.select(field(name("t", "product_id")),
field(name("t", "product_name")),
field(selectCount()
.from(PRODUCT)
.where(PRODUCT.PRODUCT_ID.eq(
field(name("t", "product_id"),
Long.class)))).as("count"))
.from(PRODUCT.as("t"))
.fetch();
以下是一些使用别名字段的示例(在这里用于完全控制 SQL 中生成的列名):
ctx.select(EMPLOYEE.FIRST_NAME.as("fn"),
EMPLOYEE.LAST_NAME.as("ln"))
.from(EMPLOYEE).fetch();
ctx.select(concat(EMPLOYEE.FIRST_NAME,
inline(" "), EMPLOYEE.LAST_NAME).as("name"),
EMPLOYEE.EMAIL.as("contact"),
EMPLOYEE.REPORTS_TO.as("boss_id"))
.from(EMPLOYEE).fetch();
接下来,我们将探讨一些使用别名的更复杂示例。
别名与 JOIN
我们看到 SQL 别名在JOIN语句中工作的一个常见情况是在JOIN语句中。人们宁愿将别名与连接的表关联起来,并通过这些别名来引用它们。例如,在下面的屏幕截图中,我们有一个没有使用别名的JOIN(顶部)和带有别名的JOIN(底部)的两个 MySQL 表(OFFICE和DEPARTMENT):

图 16.1 – 带与不带别名的 JOIN
如果我们在 jOOQ 中表达第一个 SQL(不使用别名),那么我们会得到这个结果——在 jOOQ 中,每当你可以省略别名的使用时,就去做吧!这样,你就有更好的机会获得干净的表达式,就像这里所示:
ctx.select(OFFICE.CITY, DEPARTMENT.NAME, DEPARTMENT.PROFIT)
.from(OFFICE)
.join(DEPARTMENT)
.on(OFFICE.OFFICE_CODE.eq(DEPARTMENT.OFFICE_CODE))
.fetch();
这是一个干净且可读的 jOOQ 代码片段。由于 jOOQ 代表我们生成 SQL,我们不需要添加一些别名来提高可读性或减少输入时间。
然而,接下来,让我们添加适当的别名以获得第二个 SQL。作为我们的第一次尝试,我们可能写了这个:
ctx.select(field("t1.city"),
field("t2.name"), field("t2.profit"))
.from(OFFICE.as("t1"))
.join(DEPARTMENT.as("t2"))
.on(field("t1.office_code").eq(field("t2.office_code")))
.fetch();
因此,我们通过OFFICE.as("t1")将t1别名与OFFICE表关联起来,通过DEPARTMENT.as("t2")将t2别名与DEPARTMENT表关联起来。此外,我们通过field()方法分别使用别名t1和t2。除了在 jOOQ 代码中失去一些可读性之外,你在这段代码与不带别名的 jOOQ 代码相比是否发现了其他问题?当然发现了——它不是类型安全的,并且渲染了未引号的标识符。
当我们说field("t1.city")时,jOOQ 渲染为t1.city,而不是 t1.city ``(在 MySQL 中)。然而,努力追求有资格和引号的标识符以避免名称冲突和潜在错误是明智的(例如,使用像ORDER这样的关键字作为未引号的表名会导致错误)。一般来说,引号标识符允许我们使用保留名称作为对象名称(例如,ORDER),在对象名称中使用特殊字符(空格等),并指示(大多数数据库)我们将不区分大小写的标识符视为区分大小写的标识符(例如,"address"和"ADDRESS"是不同的标识符,而address和ADDRESS`不是)。
然而,如果我们依赖于显式使用DSL.name(),jOOQ 可以渲染有资格和引号的标识符,这是一个非常方便的static方法,它有多种形式,并且对于构建用于纯 SQL 的 SQL 注入安全、语法安全的 SQL 标识符非常有用。它通常用于table()和field()方法——例如,name(table_name, field_name)——但你可以在文档中查看所有形式。以下表格表示 jOOQ 为name()方法的不同用法和不同数据库渲染的内容:

图 16.2 – 使用 jOOQ 的 name()
当一个标识符出现多次时,它可以作为一个Name提取到局部变量中,并在需要时在查询中重复使用,如下所示:
Name orderId = name("ORDER", "ORDER_ID");
Field orderId = field(name("ORDER", "ORDER_ID"));
Table t = table(name("ORDER"));
当 jOOQ 评估name("ORDER", "ORDER_ID")(针对 MySQL)时,它渲染为`ORDER`.`ORDER_ID`。当然,ORDER_ID不一定需要反引号——只需要ORDER需要。通过quotedName()和unquotedName()方法来玩转标识符的引号,如下所示:
// `ORDER`.ORDER_ID
Name orderId = name(quotedName("ORDER"),
unquotedName("ORDER_ID"));
此外,jOOQ 允许我们通过RenderQuotedNames设置来控制(全局或查询级别)标识符的引号方式,以及通过RenderNameCase设置来控制大小写。例如,我们可以指示 jOOQ 在当前查询的上半部分引用所有标识符,如下所示:
For MySQL, jOOQ render this SQL:
select `T`.`FIRST_NAME` as `FN`, `T`.`LAST_NAME` as `LN`
from `CLASSICMODELS`.`EMPLOYEE` as `T`
ctx.configuration().derive(new Settings()
.withRenderQuotedNames(RenderQuotedNames.ALWAYS)
.withRenderNameCase(RenderNameCase.UPPER))
.dsl()
.select(field(name("t", "first_name")).as("fn"),
field(name("t", "last_name")).as("ln"))
.from(EMPLOYEE.as("t"))
.fetch();
虽然您可以在文档中找到有关这些设置的更多详细信息(www.jooq.org/doc/latest/manual/sql-building/dsl-context/custom-settings/settings-name-style/),但请记住,它们仅影响通过基于 Java 的模式或name()表达的标识符。换句话说,它们对field("identifier")和table("identifier")没有影响。这些将按照您提供的方式渲染。
jOOQ 不会以任何方式强迫我们在同一个查询或多个查询中一致地使用引号、限定符和大小写(因为 jOOQ 默认渲染)。然而,处理这些方面可能会导致问题,从结果不一致到 SQL 错误。这是因为,在某些数据库(例如,SQL Server)中,标识符始终是不区分大小写的。这意味着引号仅有助于允许在标识符中使用特殊字符或转义关键字。在其他数据库(例如,Oracle)中,如果标识符未引用,则它们是不区分大小写的,而引用的标识符是大小写敏感的。然而,也存在标识符始终是大小写敏感的数据库(例如,Sybase ASE),无论它们是否被引用。再次强调,引号仅有助于允许在标识符中使用特殊字符或转义关键字。而且,我们不要忘记那些混合上述规则的方言(例如,MySQL),这些规则取决于操作系统、对象类型、配置和其他事件。
因此,请注意您如何决定处理引号、限定符和大小写敏感性的方面。最好的/最安全的方法是通过基于 Java 的模式表达查询,仅在必要时使用别名,并且如果您必须将标识符作为纯字符串引用,则始终使用name()。从那时起,让 jOOQ 做剩下的工作。
话虽如此,如果您在我们的查询中应用name(),则会得到以下代码:
ctx.select(field(name("t1", "city")),
field(name("t2", "name")), field(name("t2", "profit")))
.from(OFFICE.as(name("t1")))
.join(DEPARTMENT.as(name("t2")))
.on(field(name("t1", "office_code"))
.eq(field(name("t2", "office_code"))))
.fetch();
这次,渲染的标识符符合我们的预期,但这个 jOOQ 代码片段仍然不是类型安全的。要将这个非类型安全的查询转换为类型安全的查询,我们必须在使用之前提取别名并在局部变量中定义它们,如下所示(注意没有必要显式使用 name()):
Office t1 = OFFICE.as("t1");
Department t2 = DEPARTMENT.as("t2");
ctx.select(t1.CITY, t2.NAME, t2.PROFIT)
.from(t1)
.join(t2)
.on(t1.OFFICE_CODE.eq(t2.OFFICE_CODE))
.fetch();
或者,你可能更喜欢以下这种最小化别名方法:
Department t = DEPARTMENT.as("t");
// or, Department t = DEPARTMENT;
ctx.select(OFFICE.CITY, t.NAME, t.PROFIT)
.from(OFFICE)
.join(t)
.on(OFFICE.OFFICE_CODE.eq(t.OFFICE_CODE))
.fetch();
在生成的表上调用 as() 方法(在这里,在 OFFICE 和 DEPARTMENT 上)返回与表相同类型的对象(jooq.generated.tables.Office 和 jooq.generated.tables.Department)。这个结果对象可以用来以类型安全的方式从别名表中取消引用字段。因此,多亏了 as(),Office 和 Department,我们在使用所需的表别名时再次实现了类型安全。当然,标识符是隐式渲染、引号和限定的。
重要提示
作为一条经验法则,在 jOOQ 中,在使用查询之前,尽量在局部变量中提取和声明别名。特别是如果你的别名引用了生成的表,是应该在多个查询中重复使用的别名,你希望提高 jOOQ 表达式的可读性以及避免打字错误等。当然,如果你的 jOOQ 表达式只是将一些别名关联到列(以完全控制你 SQL 中生成的列名),那么将它们作为局部变量提取不会产生显著的改进。
让我们看看以下这样别名的表:
Table<Record1<String>> t3 =
ctx.select(t1.CITY).from(t1).asTable("t3");
在这种情况下,我们可以通过 field(Name name) 以非类型安全的方式引用字段,如下例所示:
ctx.select(t3.field(name("city")),
CUSTOMERDETAIL.CUSTOMER_NUMBER)
.from(t3)
.join(CUSTOMERDETAIL)
.on(t3.field(name("city"), String.class)
.eq(CUSTOMERDETAIL.CITY))
.fetch();
同样的 field() 方法可以应用于任何类型不安全的别名表,作为 Table<?> 返回 Field<?>。
在这种情况下,我们可以通过 <T> Field<T> field(Field<T> field) 方法(在第十四章**,派生表、CTE 和视图中介绍)使这些字段看起来是类型安全的。t3.field(name("city")) 表达式间接引用了 t1.CITY 字段,因此我们可以以类型安全的方式重写我们的查询,如下所示:
ctx.select(t3.field(t1.CITY), CUSTOMERDETAIL.CUSTOMER_NUMBER)
.from(t3)
.join(CUSTOMERDETAIL)
.on(t3.field(t1.CITY).eq(CUSTOMERDETAIL.CITY))
.fetch();
然而,请记住,Table.field(Field<T>):Field<T> 只是看起来是类型安全的。它和 Java 中的不安全转换一样好,因为查找只考虑标识符,而不是类型。它也不会强制转换表达式。
到这里为止,一切顺利!你可以在 AliasesSamples 中练习这些示例。现在,让我们花一些时间来介绍 jOOQ 别名的几个基本方面,并练习一些简单但重要的练习。
别名和 GROUP BY/ORDER BY
让我们考虑以下在 SQL Server 中表达的 SQL:
SELECT [classicmodels].[dbo].[product].[product_line] [pl]
FROM [classicmodels].[dbo].[product]
GROUP BY [classicmodels].[dbo].[product].[product_line]
ORDER BY [pl]
这个查询使用了一个名为 pl 的别名来表示 PRODUCT_LINE 列。根据我们之前学到的知识,尝试通过 jOOQ 表达这个查询可能会得到如下结果:
Field<String> pl = PRODUCT.PRODUCT_LINE.as("pl");
ctx.select(pl)
.from(PRODUCT)
.groupBy(pl)
.orderBy(pl)
.fetch();
但这并不正确!这里的问题与我们的期望有关。我们期望PRODUCT.PRODUCT_LINE.as("pl")在ORDER BY中产生[pl],在GROUP BY中产生[classicmodels].[dbo].[product].[product_line],在SELECT中产生[classicmodels].[dbo].[product].[product_line] [pl]。换句话说,我们期望局部变量pl的三种使用方式能够神奇地渲染出对我们更有意义的输出。但是,这不是真的!
考虑 jOOQ DSL 更像是表达式树。因此,我们可以将PRODUCT.PRODUCT_LINE和PRODUCT.PRODUCT_LINE.as("pl")存储在单独的局部变量中,并明确重用那个最有意义的:
Field<String> pl1 = PRODUCT.PRODUCT_LINE.as("pl");
Field<String> pl2 = PRODUCT.PRODUCT_LINE;
ctx.select(pl1)
.from(PRODUCT)
.groupBy(pl2)
.orderBy(pl1)
.fetch();
这次,这是正确的!
重要提示
在查询中重复使用x.as("y")表达式,并认为它“神奇地”产生x或y,无论哪个更有意义,这是对 jOOQ 别名的真正错误理解。认为x.as("y")在GROUP BY中生成x,在ORDER BY中生成y,在SELECT中生成x.as("y")是危险的逻辑,这会让你头疼。别名的表达式x.as("y")在SELECT之外“到处”产生y,并在SELECT中产生别名的声明(但只在SELECT中立即产生)。它“永远不会”只产生x。
你可以在AliasesSamples中练习这些示例。
别名和错误的假设
接下来,让我们考虑以下截图所示的示例:
![图 16.3 – 别名使用案例
![图片/B16833_Figure_16.3.jpg]
图 16.3 – 别名使用案例
你对(A)和(B)有什么看法?如果你说(A)是正确的,而(B)是错误的,那么你是正确的。恭喜!关于(B),由于我们给[office]表分配了一个别名,[office].[city]列就变得未知了。渲染的 SQL 突出了以下方面:
SELECT [classicmodels].[dbo].[office].[city]
FROM [classicmodels].[dbo].[office] [t]
因此,一个简单直接的方法就是简单地移除别名。现在,让我们考察几个错误的选项。首先,让我们探索这个:
// SELECT t FROM [classicmodels].[dbo].[office] [t]
ctx.select(field("t", "city"))
.from(OFFICE.as("t"))
.fetch();
这种构建基于这样的假设,即 jOOQ 公开了一个方法field(String table_name, String field_name),但实际上没有这样的方法!那么为什么前面的代码可以编译呢?因为 DSL 公开了一个field(String sql, Object... bindings),它用于 SQL 模板,它被用在了错误的环境中。注意这样的愚蠢错误!谁没有觉得幸运并试图在不阅读文档的情况下使用 API 呢?!
那么,这个怎么样?
// SELECT [t].[office_code], [t].[city], ..., [t].[location]
// FROM [classicmodels].[dbo].[office] [t]
ctx.select(table("t").field("city"))
.from(OFFICE.as("t"))
.fetch();
这只是基于错误假设的另一个例子。虽然 jOOQ 公开了一个table(String sql),这对于返回一个包装给定普通 SQL 的表很有用,但这个例子假设存在一个table(String alias),它返回一个包装别名的表,并且知道其字段。
进一步来说,让我们尝试这种方法:
// SELECT [city] FROM [classicmodels].[dbo].[office] [t]
ctx.select(field(name("city")))
.from(OFFICE.as("t"))
.fetch();
这种方法可以正常工作,但你必须意识到,未限定的[city]容易产生歧义。例如,假设我们按照以下方式丰富这个查询:
ctx.select(field(name("city")))
.from(OFFICE.as("t1"), CUSTOMERDETAIL.as("t2"))
.fetch();
这导致了一个模糊的列,[city],因为它不清楚我们是引用OFFICE.CITY还是CUSTOMERDETAIL.CITY。在这种情况下,表别名可以帮助我们清楚地表达这一点:
ctx.select(field(name("t1", "city")).as("city_office"),
field(name("t2", "city")).as("city_customer"))
.from(OFFICE.as("t1"), CUSTOMERDETAIL.as("t2"))
.fetch();
在使用之前声明别名要好得多:
Office t1 = OFFICE.as("t1");
Customerdetail t2 = CUSTOMERDETAIL.as("t2");
ctx.select(t1.CITY, t2.CITY)
.from(t1, t2)
.fetch();
Field<String> c1 = t1.CITY.as("city_office");
Field<String> c2 = t2.CITY.as("city_customer");
ctx.select(c1, c2)
.from(t1, t2)
.fetch();
现在,让我们看看另一个案例,并从以下代码片段开始。那么,这里有什么问题?
ctx.select()
.from(OFFICE
.leftOuterJoin(DEPARTMENT)
.on(OFFICE.OFFICE_CODE.eq(DEPARTMENT.OFFICE_CODE)))
.innerJoin(EMPLOYEE)
.on(EMPLOYEE.OFFICE_CODE.eq(
field(name("office_code"), String.class)))
.fetch();
在将OFFICE和DEPARTMENT连接后,结果包含两个名为office_code的列——一个来自OFFICE,另一个来自DEPARTMENT。将此结果与EMPLOYEE连接显示,ON子句中的office_code列是模糊的。为了消除这种模糊性,我们可以使用别名的表:
ctx.select()
.from(OFFICE.as("o")
.leftOuterJoin(DEPARTMENT.as("d"))
.on(field(name("o","office_code"))
.eq(field(name("d","office_code")))))
.innerJoin(EMPLOYEE)
.on(EMPLOYEE.OFFICE_CODE.eq(OFFICE.OFFICE_CODE))
.fetch();
这正确吗?这次,我们已经将别名与我们的OFFICE.as("o")和DEPARTMENT.as("d")表相关联。在将OFFICE与DEPARTMENT连接时,我们正确地使用了别名,但当我们将结果连接到EMPLOYEE时,我们没有使用OFFICE别名——我们使用了未别名的OFFICE.OFFICE_CODE。这在 MySQL 中表示为`classicmodels`.`office`.`office_code`,它代表ON子句中的一个未知列。因此,正确的表达式如下:
ctx.select()
.from(OFFICE.as("o")
.leftOuterJoin(DEPARTMENT.as("d"))
.on(field(name("o","office_code"))
.eq(field(name("d","office_code")))))
.innerJoin(EMPLOYEE)
.on(EMPLOYEE.OFFICE_CODE
.eq(field(name("o","office_code"), String.class)))
.fetch();
我们能否将其写得更紧凑且类型安全?当然可以——通过局部变量:
Office o = OFFICE.as("o");
Department d = DEPARTMENT.as("d");
ctx.select()
.from(o.leftOuterJoin(d)
.on(o.OFFICE_CODE.eq(d.OFFICE_CODE)))
.innerJoin(EMPLOYEE)
.on(EMPLOYEE.OFFICE_CODE.eq(o.OFFICE_CODE))
.fetch();
再次,局部变量帮助我们表达别名并获得优雅的代码。
别名和打字错误
接下来,让我们看看另一种在局部变量中提取别名的办法。查看以下代码:
ctx.select(field("s1.msrp"), field("s2.msrp"))
.from(PRODUCT.as("s1"), PRODUCT.as("s2"))
.where(field("s1.msrp").lt(field("s2.msrp"))
.and(field("s1.product_line").eq("s2.product_line")))
.groupBy(field("s1.msrp"), field("s2.msrp"))
.having(count().eq(selectCount().from(PRODUCT.as("s3"))
.where(field("s3.msrp").eq(field("s1.msrp"))))
.and(count().eq(selectCount().from(PRODUCT.as("s4"))
.where(field("s4.msrp").eq(field("s2.msrp"))))))
.fetch();
这个表达式中有一个错误(一个打字错误)。你能找到它吗?(这并不容易!)如果不能,你将得到一个有效的 SQL 语句,但它返回不准确的结果。这个打字错误悄悄地进入了代码的.and(field("s1.product_line").eq("s2.product_line")))部分,它应该是.and(field("s1.product_line").eq(field("s2.product_line"))))。但如果我们在局部变量中提取别名,那么代码就消除了打字错误的风险,并提高了表达式的可读性(注意s1、s2、s3和s4不是相等对象,它们不能互换使用):
Product s1 = PRODUCT.as("s1");
Product s2 = PRODUCT.as("s2");
Product s3 = PRODUCT.as("s3");
Product s4 = PRODUCT.as("s4");
ctx.select(s1.MSRP, s2.MSRP)
.from(s1, s2)
.where(s1.MSRP.lt(s2.MSRP)
.and(s1.PRODUCT_LINE.eq(s2.PRODUCT_LINE)))
.groupBy(s1.MSRP, s2.MSRP)
.having(count().eq(selectCount().from(s3)
.where(s3.MSRP.eq(s1.MSRP)))
.and(count().eq(selectCount().from(s4)
.where(s4.MSRP.eq(s2.MSRP)))))
.fetch();
你可以在AliasesSamples中练习这些例子。
别名和派生表
让我们看看另一个例子,它从以下代码片段开始:
ctx.select().from(
select(CUSTOMER.CUSTOMER_NUMBER,
CUSTOMER.CUSTOMER_NAME, field("t.invoice_amount"))
.from(CUSTOMER)
.join(select(PAYMENT.CUSTOMER_NUMBER,
PAYMENT.INVOICE_AMOUNT)
.from(PAYMENT).asTable("t"))
.on(field("t.customer_number")
.eq(CUSTOMER.CUSTOMER_NUMBER)))
.fetch();
那么,这里有什么问题?!让我们检查生成的 SQL(这是针对 MySQL 的):
SELECT `alias_84938429`.`customer_number`,
`alias_84938429`.`customer_name`,
`alias_84938429`.t.invoice_amount
FROM
(SELECT `classicmodels`.`customer`.`customer_number`,
`classicmodels`.`customer`.`customer_name`,
t.invoice_amount
FROM `classicmodels`.`customer`
JOIN
(SELECT `classicmodels`.`payment`.`customer_number`,
`classicmodels`.`payment`.`invoice_amount`
FROM `classicmodels`.`payment`) AS `t` ON
t.customer_number =
`classicmodels`.`customer`.`customer_number`)
AS `alias_84938429
如您所见,jOOQ 已自动将一个别名与从JOIN获得的派生表(alias_84938429)相关联,并使用此别名来引用customer_number、customer_name和invoice_amount。虽然customer_number和customer_name被正确地限定和引用,但invoice_amount被错误地表示为t.invoice_amount。问题在于field("t.invoice_amount"),它指示 jOOQ 列名是t.invoice_amount,而不是invoice_amount,因此结果`alias_84938429`.t.invoice_amount是一个未知列。
有几种解决方案可以解决这个问题,其中之一是使用name()来进行适当的引号和限定:
ctx.select().from(select(CUSTOMER.CUSTOMER_NUMBER,
CUSTOMER.CUSTOMER_NAME, field(name("t", "invoice_amount")))
.from(CUSTOMER)
.join(
select(PAYMENT.CUSTOMER_NUMBER,
PAYMENT.INVOICE_AMOUNT)
.from(PAYMENT).asTable("t"))
.on(field(name("t", "customer_number"))
.eq(CUSTOMER.CUSTOMER_NUMBER)))
.fetch();
这次,jOOQ 渲染了 alias_10104609.invoice_amount` ``。在捆绑的代码中,您可以看到针对此问题的四个更多解决方案。
为了理解这个上下文,让我们看看以下示例:
ctx.select()
.from(select(EMPLOYEE.EMPLOYEE_NUMBER.as("en"),
EMPLOYEE.SALARY.as("sal"))
.from(EMPLOYEE)
.where(EMPLOYEE.MONTHLY_BONUS.isNull()))
.innerJoin(SALE)
.on(field(name("en"))
.eq(SALE.EMPLOYEE_NUMBER))
.fetch();
在这里,我们明确地将列别名与内部SELECT关联起来,但没有将别名与由JOIN产生的派生表关联起来。这些别名进一步用于引用此SELECT(外部SELECT)之外的列。请注意,我们让 jOOQ 将这些别名限定为生成的分割表的别名:
SELECT `alias_41049514`.`en`,
`alias_41049514`.`sal`,
`classicmodels`.`sale`.`sale_id`,
...
FROM
(SELECT `classicmodels`.`employee`.`employee_number`
AS `en`, `classicmodels`.`employee`.`salary` AS `sal`
FROM `classicmodels`.`employee`
WHERE `classicmodels`.`employee`.`monthly_bonus` IS NULL
) AS `alias_41049514`
JOIN `classicmodels`.`sale` ON `en` =
`classicmodels`.`sale`.`employee_number`
如果我们想要控制派生表的别名,则可以这样做:
ctx.select(SALE.SALE_, SALE.FISCAL_YEAR,
field(name("t", "sal")))
.from(select(EMPLOYEE.EMPLOYEE_NUMBER.as("en"),
EMPLOYEE.SALARY.as("sal"))
.from(EMPLOYEE)
.where(EMPLOYEE.MONTHLY_BONUS.isNull())
.asTable("t"))
.innerJoin(SALE)
.on(field(name("t", "en"))
.eq(SALE.EMPLOYEE_NUMBER))
.fetch();
这次,渲染的 SQL 使用了我们的表别名:
SELECT `classicmodels`.`sale`.`sale`,
`classicmodels`.`sale`.`fiscal_year`,
`t`.`sal`
FROM
(SELECT `classicmodels`.`employee`.`employee_number`
AS `en`, `classicmodels`.`employee`.`salary` AS `sal`
FROM `classicmodels`.`employee`
WHERE `classicmodels`.`employee`.`monthly_bonus`
IS NULL) AS `t`
JOIN `classicmodels`.`sale` ON `t`.`en` =
`classicmodels`.`sale`.`employee_number`
最后,这里是一个使用别名的更详细示例:
ctx.select(field(name("t2", "s")).as("c1"),
field(name("t2", "y")).as("c2"),
field(name("t2", "i")).as("c3"))
.from(select(SALE.SALE_.as("s"), SALE.FISCAL_YEAR.as("y"),
field(name("t1", "emp_sal")).as("i"))
.from(select(EMPLOYEE.EMPLOYEE_NUMBER.as("emp_nr"),
EMPLOYEE.SALARY.as("emp_sal"))
.from(EMPLOYEE)
.where(EMPLOYEE.MONTHLY_BONUS.isNull())
.asTable("t1"))
.innerJoin(SALE)
.on(field(name("t1","emp_nr"))
.eq(SALE.EMPLOYEE_NUMBER)).asTable("t2"))
.fetch();
请花时间分析这个表达式和生成的 SQL:
SELECT `t2`.`s` AS `c1`,
`t2`.`y` AS `c2`,
`t2`.`i` AS `c3`
FROM
(SELECT `classicmodels`.`sale`.`sale` AS `s`,
`classicmodels`.`sale`.`fiscal_year` AS `y`,
`t1`.`emp_sal` AS `i`
FROM
(SELECT `classicmodels`.`employee`.`employee_number`
AS `emp_nr`,
`classicmodels`.`employee`.`salary`
AS `emp_sal`
FROM `classicmodels`.`employee`
WHERE `classicmodels`.`employee`.`monthly_bonus`
IS NULL) AS `t1`
JOIN `classicmodels`.`sale` ON `t1`.`emp_nr` =
`classicmodels`.`sale`.`employee_number`) AS `t2`
现在,让我们看看更多使用别名的例子。
派生列列表
当列名事先未知(但表度已知!)时,我们可以使用所谓的派生列列表。您已经看到了许多使用此功能与未嵌套表的例子,所以这里再提供两个关于VALUES()表构造器和常规表的例子:
ctx.select().from(values(row("A", "John", 4333, false))
.as("T", "A", "B", "C", "D")).fetch();
以下代码是针对常规表的:
ctx.select(min(field(name("t", "rdate"))).as("cluster_start"),
max(field(name("t", "rdate"))).as("cluster_end"),
min(field(name("t", "status"))).as("cluster_status"))
.from(select(ORDER.REQUIRED_DATE, ORDER.STATUS,
rowNumber().over().orderBy(ORDER.REQUIRED_DATE)
.minus(rowNumber().over().partitionBy(ORDER.STATUS)
.orderBy(ORDER.REQUIRED_DATE)))
.from(ORDER)
.asTable("t", "rdate", "status", "cluster"))
.groupBy(field(name("t", "cluster")))
.orderBy(1)
.fetch();
如果您不熟悉这类别名,请花时间检查渲染的 SQL 并阅读一些文档。
别名和 CASE 表达式
别名也可以与CASE表达式一起使用。以下是一个示例:
ctx.select(EMPLOYEE.SALARY,
count(case_().when(EMPLOYEE.SALARY
.gt(0).and(EMPLOYEE.SALARY.lt(50000)), 1)).as("< 50000"),
count(case_().when(EMPLOYEE.SALARY.gt(50000)
.and(EMPLOYEE.SALARY.lt(100000)), 1)).as("50000 - 100000"),
count(case_().when(EMPLOYEE.SALARY
.gt(100000), 1)).as("> 100000"))
.from(EMPLOYEE)
.groupBy(EMPLOYEE.SALARY)
.fetch();
它们也可以用于FILTER WHERE表达式:
ctx.select(EMPLOYEE.SALARY,
count().filterWhere(EMPLOYEE.SALARY
.gt(0).and(EMPLOYEE.SALARY.lt(50000))).as("< 50000"),
count().filterWhere(EMPLOYEE.SALARY.gt(50000)
.and(EMPLOYEE.SALARY.lt(100000))).as("50000 - 100000"),
count().filterWhere(EMPLOYEE.SALARY
.gt(100000)).as("> 100000"))
.from(EMPLOYEE)
.groupBy(EMPLOYEE.SALARY)
.fetch();
如您所见,在CASE/FILTER表达式中使用别名非常方便,因为它允许我们更好地表达每个案例的含义。
别名和 IS NOT NULL
如果我们将Condition包裹在field()中,以获得Field<Boolean>,则可以使用别名与IS NOT NULL(及其相关项)一起使用:
ctx.select(EMPLOYEE.FIRST_NAME,
EMPLOYEE.LAST_NAME, EMPLOYEE.COMMISSION,
field(EMPLOYEE.COMMISSION.isNotNull()).as("C"))
.from(EMPLOYEE)
.fetch();
最后,让我们快速看一下别名和 CTE。
别名和 CTE(公用表表达式)
在第十四章“派生表、CTE 和视图”中,我们探讨了在 CTE 和派生表中使用别名的许多示例,所以如果您想熟悉这个主题,请考虑这一章。接下来,让我们谈谈 SQL 模板。
SQL 模板
当我们谈论 SQL 模板或纯 SQL 模板语言时,我们是在谈论那些 DSL 无法帮助我们表达 SQL 的情况。jOOQ DSL 通过不断添加更多功能,力求尽可能覆盖 SQL,但仍然可能会发现一些角落案例语法或供应商特定的功能不会被 DSL 覆盖。在这种情况下,jOOQ 允许我们通过纯 SQL 字符串或查询部分({0},{1},……)使用 Plain SQL API 来表示 SQL。
纯 SQL API 在一系列重载方法中实现,可以在 DSL 无法提供帮助的地方使用。以下是一些示例:
field/table(String sql)
field(String sql, Class<T> type)
field(String sql, Class<T> type, Object... bindings)
field(String sql, Class<T> type, QueryPart... parts)
field/table(String sql, Object... bindings)
field(String sql, DataType<T> type)
field(String sql, DataType<T> type, Object... bindings)
field(String sql, DataType<T> type, QueryPart... parts)
field/table(String sql, QueryPart... parts)
from/where/join …(String string)
from/where/join …(String string, Object... os)
from/where/join …(String string, QueryPart... qps)
因此,我们可以按以下方式传递 SQL:
-
纯 SQL 字符串
-
纯 SQL 字符串和绑定(?)
-
纯 SQL 字符串和
QueryPart
绑定和查询部分重载使用所谓的纯 SQL 模板语言。
这里有一些使用纯 SQL 和绑定值的示例(这些示例在 SQLTemplating 中可用):
ctx.fetch("""
SELECT first_name, last_name
FROM employee WHERE salary > ? AND job_title = ?
""", 5000, "Sales Rep");
ctx.resultQuery("""
SELECT first_name, last_name
FROM employee WHERE salary > ? AND job_title = ?
""", 5000, "Sales Rep")
.fetch();
ctx.query("""
UPDATE product SET product.quantity_in_stock = ?
WHERE product.product_id = ?
""", 0, 2)
.execute();
ctx.queries(query(""), query(""), query(""))
.executeBatch();
现在,让我们看看一些示例,帮助你熟悉将纯 SQL 与通过 DSL 表达的 SQL 混合的技术。让我们考虑以下 MySQL 查询:
SELECT `classicmodels`.`office`.`office_code`,
...
`classicmodels`.`customerdetail`.`customer_number`,
...
FROM `classicmodels`.`office`
JOIN `classicmodels`.`customerdetail`
ON `classicmodels`.`office`.`postal_code` =
`classicmodels`.`customerdetail`.`postal_code`
WHERE not((
`classicmodels`.`office`.`city`,
`classicmodels`.`office`.`country`)
<=> (`classicmodels`.`customerdetail`.`city`,
`classicmodels`.`customerdetail`.`country`))
如果你是一个 jOOQ 新手,并且试图通过 jOOQ DSL 表达此查询,那么你可能会在突出显示的代码中遇到一些问题。我们能否通过 DSL 表达这部分?答案是肯定的,但如果找不到适当的解决方案,我们也可以将其嵌入为纯 SQL。以下是代码:
ctx.select()
.from(OFFICE)
.innerJoin(CUSTOMERDETAIL)
.on(OFFICE.POSTAL_CODE.eq(CUSTOMERDETAIL.POSTAL_CODE))
.where("""
not(
(
`classicmodels`.`office`.`city`,
`classicmodels`.`office`.`country`
) <=> (
`classicmodels`.`customerdetail`.`city`,
`classicmodels`.`customerdetail`.`country`
)
)
""")
.fetch();
完成!当然,一旦你更熟悉 jOOQ DSL,你将能够通过 DSL 100% 表达此查询,并让 jOOQ 适当模拟(更好!):
ctx.select()
.from(OFFICE)
.innerJoin(CUSTOMERDETAIL)
.on(OFFICE.POSTAL_CODE.eq(CUSTOMERDETAIL.POSTAL_CODE))
.where(row(OFFICE.CITY, OFFICE.COUNTRY)
.isDistinctFrom(row(
CUSTOMERDETAIL.CITY, CUSTOMERDETAIL.COUNTRY)))
.fetch();
但有时,你需要 SQL 模板。例如,MySQL 定义了一个函数,CONCAT_WS(separator, exp1, exp2, exp3,...),该函数使用给定的分隔符将两个或多个表达式组合在一起。此函数没有 jOOQ 对应项,因此我们可以通过 SQL 模板(这里为纯 SQL 和查询部分)使用它,如下所示:
ctx.select(PRODUCT.PRODUCT_NAME,
field("CONCAT_WS({0}, {1}, {2})",
String.class, val("-"),
PRODUCT.BUY_PRICE, PRODUCT.MSRP))
.from(PRODUCT)
.fetch();
由于要连接的部分数量可能不同,因此依赖方便的 DSL.list(QueryPart...) 会更实用,它允许我们在单个模板参数中定义逗号分隔的查询部分列表:
ctx.select(PRODUCT.PRODUCT_NAME,
field("CONCAT_WS({0}, {1})",
String.class, val("-"),
list(PRODUCT.BUY_PRICE, PRODUCT.MSRP)))
.from(PRODUCT)
.fetch();
这次,模板参数 {1} 已被替换为应连接的字符串列表。现在,你可以简单地传递那个列表。
jOOQ DSL 也不支持 MySQL 变量 (@variable)。例如,你将如何表达以下使用 @type 和 @num 变量的 MySQL 查询?
SELECT `classicmodels`.`employee`.`job_title`,
`classicmodels`.`employee`.`salary`,
@num := if(@type = `classicmodels`.`employee`.
`job_title`, @num + 1, 1) AS `rn`,
@type := `classicmodels`.`employee`.`job_title` AS `dummy`
FROM `classicmodels`.`employee`
ORDER BY `classicmodels`.`employee`.`job_title`,
`classicmodels`.`employee`.`salary`
这里的 SQL 模板可以救命:
ctx.select(EMPLOYEE.JOB_TITLE, EMPLOYEE.SALARY,
field("@num := if(@type = {0}, @num + 1, 1)",
EMPLOYEE.JOB_TITLE).as("rn"),
field("@type := {0}", EMPLOYEE.JOB_TITLE).as("dummy"))
.from(EMPLOYEE)
.orderBy(EMPLOYEE.JOB_TITLE, EMPLOYEE.SALARY)
.fetch();
你可以在 SQLTemplating 中练习这些示例以及其他示例。
当我们需要处理某些数据类型时,SQL 模板也非常有用,例如 PostgreSQL 的 HSTORE 数据类型。我们知道 jOOQ 允许我们定义转换器和绑定,特别是用于处理此类类型。在 第七章,类型、转换器和绑定 中,我们为 HSTORE 数据类型编写了一个 org.jooq.Converter 和一个 org.jooq.Binding。此外,jooq-postgres-extensions 模块也支持 HSTORE。
然而,使用 SQL 模板也可以是一个快速解决方案——例如,你可能只需要编写几个查询,而没有时间编写转换器/绑定。我们可以通过 SQL 模板将此插入到我们的 HSTORE (PRODUCT.SPECS) 中,如下所示:
ctx.insertInto(PRODUCT, PRODUCT.PRODUCT_NAME,
PRODUCT.PRODUCT_LINE, PRODUCT.CODE, PRODUCT.SPECS)
.values("2002 Masserati Levante", "Classic Cars",
599302L, field("?::hstore", String.class,
HStoreConverter.toString(Map.of("Length (in)",
"197", "Width (in)", "77.5", "Height (in)",
"66.1", "Engine", "Twin Turbo Premium Unleaded
V-6"))))
.execute();
我们可以像这样从 HSTORE 中选择所有内容:
List<Map<String, String>> specs =
ctx.select(PRODUCT.SPECS.coerce(String.class))
.from(PRODUCT)
.where(PRODUCT.PRODUCT_NAME.eq("2002 Masserati Levante"))
.fetch(rs -> {
return HStoreConverter.fromString(
rs.getValue(PRODUCT.SPECS).toString());
});
注意,这两个示例都依赖于 org.postgresql.util.HStoreConverter。
对 HSTORE 执行的其他操作依赖于特定供应商的运算符。使用此类运算符是 SQL 模板的一个完美工作。例如,可以通过尊重 PostgreSQL 语法来获取 HSTORE 的条目,如下所示:
ctx.select(PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_NAME,
field("{0} -> {1}", String.class, PRODUCT.SPECS,
val("Engine")).as("engine"))
.from(PRODUCT)
.where(field("{0} -> {1}", String.class, PRODUCT.SPECS,
val("Length (in)")).eq("197"))
.fetch();
或者,我们可以按键删除条目,如下所示:
ctx.update(PRODUCT)
.set(PRODUCT.SPECS, (field("delete({0}, {1})",
Record.class, PRODUCT.SPECS, val("Engine"))))
.execute();
我们还可以将 HSTORE 转换为 JSON:
ctx.select(PRODUCT.PRODUCT_NAME,
field("hstore_to_json ({0}) json", PRODUCT.SPECS))
.from(PRODUCT)
.fetch();
更多示例可在捆绑的代码中找到——SQLTemplating for PostgreSQL。如果你需要这些运算符更频繁,那么你应该在静态/实用方法中检索它们的 SQL 模板代码,并简单地调用这些方法。例如,一个按键获取的方法可以表示如下:
public static Field<String> getByKey(
Field<Map<String, String>> hstore, String key) {
return field("{0} -> {1}", String.class, hstore, val(key));
}
我们还可以通过 SQL 模板定义 CTE。以下是通过 ResultQuery 和 SQL 模板定义 CTE 的示例:
Result<Record1<BigDecimal>> msrps = ctx.resultQuery(
"with \"updatedMsrp\" as ({0}) {1}",
update(PRODUCT).set(PRODUCT.MSRP,
PRODUCT.MSRP.plus(PRODUCT.MSRP.mul(0.25)))
.returning(PRODUCT.MSRP),
select().from(name("updatedMsrp")))
.coerce(PRODUCT.MSRP)
.fetch();
这段代码仍然使用 ResultQuery 和 SQL 模板,但这次,原始 SQL 看起来如下:
Result<Record1<BigDecimal>> msrps = ctx.resultQuery(
"with \"updatedMsrp\" as ({0}) {1}",
resultQuery("""
update
"public"."product"
set
"msrp" = (
"public"."product"."msrp" + (
"public"."product"."msrp" * 0.25
)
) returning "public"."product"."msrp"
"""),
resultQuery("""
select *
from "updatedMsrp"
"""))
.coerce(PRODUCT.MSRP)
.fetch();
更多示例可在 SQLTemplating for PostgreSQL 中找到。
我们是否可以调用一些 SQL Server 函数?让我们尝试调用一个返回整数的函数,该整数衡量两个不同字符表达式的 SOUNDEX() 值之间的差异。是的——DIFFERENCE() 函数:
ctx.select(field("DIFFERENCE({0}, {1})",
SQLDataType.INTEGER, "Juice", "Jucy"))
.fetch();
我们是否可以调用 FORMAT() 函数?
ctx.select(field("FORMAT({0}, {1})",
123456789, "##-##-#####"))
.fetch();
现在,让我们尝试以下使用 SQL Server 本地变量的 SQL Server 批处理:
DECLARE @var1 VARCHAR(70)
select @var1=(select
[classicmodels].[dbo].[product].[product_name]
from [classicmodels].[dbo].[product]
where [classicmodels].[dbo].[product].[product_id] = 1)
update [classicmodels].[dbo].[product]
set [classicmodels].[dbo].[product].[quantity_in_stock] = 0
where [classicmodels].[dbo].[product].[product_name] = @var1
再次,结合 SQL 和 SQL 模板可以解决问题:
ctx.batch(
query("DECLARE @var1 VARCHAR(70)"),
select(field("@var1=({0})", select(PRODUCT.PRODUCT_NAME)
.from(PRODUCT).where(PRODUCT.PRODUCT_ID.eq(1L)))),
update(PRODUCT).set(PRODUCT.QUANTITY_IN_STOCK, 0)
.where(PRODUCT.PRODUCT_NAME
.eq(field("@var1", String.class)))
).execute();
你可以在 SQLTemplating for SQL Server 中练习这些示例。
到目前为止,我们已经看到了针对 MySQL、PostgreSQL 和 SQL Server 的特定示例。最后,让我们为 Oracle 添加一个示例。例如,如果你计划更新/删除由 SELECT FOR UPDATE 语句引用的记录,你可以使用 WHERE CURRENT OF 语句。以下示例使用 SQL 模板构建这样一个 SQL 示例:
String sql = ctx.resultQuery("{0} WHERE CURRENT OF cur",
deleteFrom(PRODUCT)).getSQL();
SQL 代码如下:
delete from "CLASSICMODELS"."PRODUCT" WHERE CURRENT OF cur
你可以在 SQLTemplating for Oracle 中练习这些示例。
此外,特别是对于需要复杂 SQL 子句的边缘情况,jOOQ 提供了一组在官方文档中得到了很好例证的类:www.jooq.org/doc/latest/manual/sql-building/queryparts/custom-queryparts/。
摘要
这是一章简短但全面的关于 jOOQ 别名和 SQL 模板的内容。在 jOOQ 中,大多数时候,你可以在不成为这些功能的强大用户的情况下过上平静的生活,但当他们发挥作用时,了解它们的基础并利用它们是件好事。
在下一章中,我们将处理多租户。
第十七章:jOOQ 中的多租户
有时,我们的应用程序需要在多租户环境中运行,即在一个操作多个租户(不同的数据库、不同的表,或者更一般地说,逻辑上隔离但物理上集成的不同实例)的环境中。在本章中,我们将根据以下议程介绍 jOOQ 在多租户环境中的常见用例:
-
通过
RenderMappingAPI 连接到每个角色/登录的单独数据库 -
通过连接切换连接到每个角色/登录的单独数据库
-
为同一供应商的两个模式生成代码
-
为不同供应商的两个模式生成代码
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter17。
通过RenderMapping API 连接到每个角色/登录的单独数据库
每个角色/登录连接到单独的数据库是多租户的经典用例。通常,你有一个主数据库(让我们称它为development数据库)和几个具有相同模式的其它数据库(让我们称它们为stage数据库和test数据库)。这三个数据库属于同一个供应商(在这里,MySQL)并且具有相同的模式,但它们存储着不同角色、账户、组织、合作伙伴等应用的数据。
为了简单起见,development数据库有一个名为product的单个表。这个数据库用于生成 jOOQ 工件,但我们希望根据当前角色(当前登录用户)在stage或test数据库上执行查询。
这种实现的关键在于对 jOOQ RenderMapping API 的巧妙运用。jOOQ 允许我们在运行时指定输入模式(例如,development)和输出模式(例如,stage),在查询中,它将渲染输出模式。代码的高潮在于这些设置,正如你所见(认证特定于 Spring Security API):
Authentication auth = SecurityContextHolder
.getContext().getAuthentication();
String authority = auth.getAuthorities().iterator()
.next().getAuthority();
String database = authority.substring(5).toLowerCase();
ctx.configuration().derive(new Settings()
.withRenderMapping(new RenderMapping()
.withSchemata(
new MappedSchema().withInput("development")
.withOutput(database)))).dsl()
.insertInto(PRODUCT, PRODUCT.PRODUCT_NAME,
PRODUCT.QUANTITY_IN_STOCK)
.values("Product", 100)
.execute();
根据当前认证用户的角色,jOOQ 渲染预期的数据库名称(例如,`stage`.`product`或`test`.`product`)。基本上,每个用户都有一个角色(例如,ROLE_STAGE或ROLE_TEST;为了简单起见,用户只有一个角色),我们通过移除ROLE_并将剩余文本转换为小写来提取输出数据库名称;按照惯例,提取的文本代表数据库名称。当然,你可以使用用户名、组织名称或任何适合你情况的约定。
你可以在名为MT的 MySQL 应用程序中测试这个示例。
withInput()方法接受输入模式的完整名称。如果你想将输入模式的名称与正则表达式匹配,那么不是使用withInput(),而是使用withInputExpression(Pattern.compile("reg_exp"))(例如,("development_(.*)"))。
如果你在一个支持目录的数据库中(例如,SQL Server),那么只需使用MappedCatalog()和withCatalogs(),如下面的示例所示:
String catalog = …;
Settings settings = new Settings()
.withRenderMapping(new RenderMapping()
.withCatalogs(new MappedCatalog()
.withInput("development")
.withOutput(catalog))
.withSchemata(…); // optional, if you need schema as well
如果你不需要运行时模式,而是需要在代码生成时硬编码映射(jOOQ 始终在运行时渲染,符合这些设置),那么对于 Maven,使用以下内容:
<database>
<schemata>
<schema>
<inputSchema>…</inputSchema>
<outputSchema>…</outputSchema>
</schema>
</schemata>
</database>
对于 Gradle,使用以下内容:
database {
schemata {
schema {
inputSchema = '…'
outputSchema = '…'
}
}
}
使用以下内容进行程序化操作:
new org.jooq.meta.jaxb.Configuration()
.withGenerator(new Generator()
.withDatabase(new Database()
.withSchemata(
new SchemaMappingType()
.withInputSchema("...")
.withOutputSchema("...")
)
)
)
你可以在MTM中看到这样的示例,用于 MySQL。正如你所看到的,所有账户/角色都针对在代码生成时硬编码的数据库(stage数据库)进行操作。
如果你使用的是支持目录的数据库(例如,SQL Server),那么只需简单地依赖<catalogs>、<catalog>、<inputCatalog>和<outputCatalog>。对于 Maven,使用以下内容:
<database>
<catalogs>
<catalog>
<inputCatalog>…</inputCatalog>
<outputCatalog>…</outputCatalog>
<!-- Optionally, if you need schema mapping -->
<schemata>
</schemata>
</catalog>
</catalogs>
</database>
对于 Gradle,使用以下内容:
database {
catalogs {
catalog {
inputCatalog = '…'
outputCatalog = '…'
// Optionally, if you need schema mapping
schemata {}
}
}
}
对于程序化操作,使用以下内容:
new org.jooq.meta.jaxb.Configuration()
.withGenerator(new Generator()
.withDatabase(new Database()
.withCatalogs(
new CatalogMappingType()
.withInputCatalog("...")
.withOutputCatalog("...")
// Optionally, if you need schema mapping
.withSchemata()
)
)
)
到目前为止,development数据库有一个名为product的单个表。这个表在stage和test数据库中也有相同的名称,但让我们假设我们决定在development数据库中将其称为product_dev,在stage数据库中称为product_stage,在test数据库中称为product_test。在这种情况下,即使 jOOQ 正确渲染了每个角色的数据库名称,它也没有正确渲染表名。幸运的是,jOOQ 允许我们通过withTables()和MappedTable()配置这个方面,如下所示:
ctx.configuration().derive(new Settings()
.withRenderMapping(new RenderMapping()
.withSchemata(
new MappedSchema().withInput("development")
.withOutput(database)
.withTables(
new MappedTable().withInput("product_dev")
.withOutput("product_" + database))))).dsl()
.insertInto(PRODUCT_DEV, PRODUCT_DEV.PRODUCT_NAME,
PRODUCT_DEV.QUANTITY_IN_STOCK)
.values("Product", 100)
.execute();
你可以在名为MTT的应用程序中查看这个示例,用于 MySQL。
通过连接切换每个角色/登录的独立数据库
另一个快速连接到每个角色/登录的独立数据库的解决方案是在运行时切换到适当的连接。为了完成这个任务,我们必须抑制 jOOQ 默认渲染模式/目录名称的行为。这样,我们就不必担心连接到数据库A,而是让数据库B在我们的表前显示,依此类推。换句话说,我们需要无限定名称。
jOOQ 允许我们通过withRenderSchema(false)和withRenderCatalog(false)设置关闭渲染模式/目录名称。以下示例连接到与登录用户角色同名的数据库,并抑制渲染模式/目录名称:
Authentication auth = SecurityContextHolder
.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
String authority = auth.getAuthorities()
.iterator().next().getAuthority();
String database = authority.substring(5).toLowerCase();
DSL.using(
"jdbc:mysql://localhost:3306/" + database,
"root", "root")
.configuration().derive(new Settings()
.withRenderCatalog(Boolean.FALSE)
.withRenderSchema(Boolean.FALSE))
.dsl()
.insertInto(PRODUCT, PRODUCT.PRODUCT_NAME,
PRODUCT.QUANTITY_IN_STOCK)
.values("Product", 100)
.execute();
}
你可以在名为MTC的应用程序中查看这个示例,用于 MySQL。
或者,我们可以通过outputSchemaToDefault标志来指示 jOOQ 从生成的代码中移除任何模式引用。对于 Maven,使用以下内容:
<outputSchemaToDefault>true</outputSchemaToDefault>
对于 Gradle,使用以下内容:
outputSchemaToDefault = true
由于生成的代码中没有更多的模式引用,生成的类可以在所有你的模式上运行:
String database = …;
DSL.using(
"jdbc:mysql://localhost:3306/" + database,
"root", "root")
.insertInto(PRODUCT, PRODUCT.PRODUCT_NAME,
PRODUCT.QUANTITY_IN_STOCK)
.values("Product", 100)
.execute();
你可以在名为MTCO的应用程序中测试这个示例,用于 MySQL。
为同一供应商的两个架构方案生成代码
考虑两个名为db1和db2的同一供应商的架构方案。在第一个架构(db1)中,我们有一个名为productline的表,在第二个架构(db2)中,我们有一个名为product的表。我们的目标是生成这两个相同供应商架构的 jOOQ 工件(以运行 jOOQ 代码生成器),并对其中一个或另一个执行查询,甚至连接这两个表。
基本上,只要我们不指定任何输入架构,jOOQ 就会为它找到的所有架构生成代码。但因为我们想指示 jOOQ 只在工作在db1和db2架构上,我们可以这样做(这里针对 Maven):
<database>
<schemata>
<schema>
<inputSchema>db1</inputSchema>
</schema>
<schema>
<inputSchema>db2</inputSchema>
</schema>
</schemata>
</database>
我相信您现在有足够的经验来直观地了解如何为 Gradle 或程序编写这些代码,所以我会跳过这些示例。
一旦我们运行了 jOOQ 代码生成器,我们就可以执行查询,如下所示:
ctx.select().from(DB1.PRODUCTLINE).fetch();
ctx.select().from(DB2.PRODUCT).fetch();
或者,这里是一个PRODUCTLINE和PRODUCT的连接:
ctx.select(DB1.PRODUCTLINE.PRODUCT_LINE,
DB2.PRODUCT.PRODUCT_ID, DB2.PRODUCT.PRODUCT_NAME,
DB2.PRODUCT.QUANTITY_IN_STOCK)
.from(DB1.PRODUCTLINE)
.join(DB2.PRODUCT)
.on(DB1.PRODUCTLINE.PRODUCT_LINE
.eq(DB2.PRODUCT.PRODUCT_LINE))
.fetch();
DB1和DB2被静态导入,如下所示:
import static jooq.generated.db1.Db1.DB1;
import static jooq.generated.db2.Db2.DB2;
完整的示例可在名为MTJ的应用程序中找到,适用于 MySQL。
为两个不同供应商的架构方案生成代码
考虑两个不同供应商的架构方案——例如,我们为 MySQL 和 PostgreSQL 设计的classicmodels架构。我们的目标是生成这两个架构的 jOOQ 工件,并对其中一个或另一个执行查询。
考虑一个基于 Maven 的应用程序,我们可以通过使用两个<execution>条目来完成这个任务,一个是flyway-maven-plugin插件,另一个是jooq-codegen-maven插件。以下是jooq-codegen-maven的骨架代码(完整的代码可在捆绑的代码中找到):
<plugin>
<groupId>org.jooq</groupId>
<artifactId>jooq-codegen-maven</artifactId>
<executions>
<execution>
<id>generate-mysql</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration xmlns="... jooq-codegen-3.16.0.xsd">
... <!-- MySQL schema configuration -->
</configuration>
</execution>
<execution>
<id>generate-postgresql</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration xmlns="...jooq-codegen-3.16.0.xsd">
... <!-- PostgreSQL schema configuration -->
</configuration>
</execution>
</executions>
</plugin>
接下来,jOOQ 为这两个供应商生成工件,我们可以在这两个连接和表之间切换,如下所示:
DSL.using(
"jdbc:mysql://localhost:3306/classicmodels",
"root", "root")
.select().from(mysql.jooq.generated.tables.Product.PRODUCT)
.fetch();
DSL.using(
"jdbc:postgresql://localhost:5432/classicmodels",
"postgres", "root")
.select().from(
postgresql.jooq.generated.tables.Product.PRODUCT)
.fetch();
或者,考虑到我们已经程序化配置了我们的DataSource对象,我们也可以配置两个DSLContext(完整的代码可在捆绑的代码中找到):
@Bean(name="mysqlDSLContext")
public DSLContext mysqlDSLContext(@Qualifier("configMySql")
DataSourceProperties properties) {
return DSL.using(
properties.getUrl(), properties.getUsername(),
properties.getPassword());
}
@Bean(name="postgresqlDSLContext")
public DSLContext postgresqlDSLContext(
@Qualifier("configPostgreSql")
DataSourceProperties properties) {
return DSL.using(
properties.getUrl(), properties.getUsername(),
properties.getPassword());
}
您还可以注入这两个DSLContext并使用您想要的任何一个:
private final DSLContext mysqlCtx;
private final DSLContext postgresqlCtx;
public ClassicModelsRepository(
@Qualifier("mysqlDSLContext") DSLContext mysqlCtx,
@Qualifier("postgresqlDSLContext") DSLContext postgresqlCtx){
this.mysqlCtx = mysqlCtx;
this.postgresqlCtx = postgresqlCtx;
}
…
mysqlCtx.select().from(
mysql.jooq.generated.tables.Product.PRODUCT).fetch();
postgresqlCtx.select().from(
postgresql.jooq.generated.tables.Product.PRODUCT).fetch();
完整的代码命名为MT2DB。如果您只想根据活动配置文件生成一个供应商的工件,那么您会喜欢MP应用程序。
摘要
多租户不是一个常规任务,但了解 jOOQ 非常灵活,允许我们在几秒钟内配置多个数据库/架构是很好的。此外,正如您刚才看到的,jOOQ + Spring Boot 组合是完成多租户任务的完美匹配。
在下一章中,我们将讨论 jOOQ SPI。
第五部分:微调 jOOQ、日志记录和测试
在本部分,我们将介绍如何通过配置和设置来微调 jOOQ。此外,我们还将讨论如何记录 jOOQ 输出和进行测试。
到本部分结束时,你将了解如何微调 jOOQ,如何记录 jOOQ 输出,以及如何编写测试。
本部分包含以下章节:
-
第十八章,jOOQ SPI(提供者和监听器)
-
第十九章,日志记录和测试
第十八章:jOOQ SPI(提供者和监听器)
jOOQ 提供了许多钩子,允许我们在不同级别更改其默认行为。在这些钩子中,我们有轻量级的设置和配置,以及由生成器、提供者、监听器、解析器等组成的重型、极其稳定的 服务提供者接口 (SPI)。因此,就像任何强大而成熟的技术一样,jOOQ 携带了一个令人印象深刻的 SPI,专门用于核心技术无法帮助的边缘情况。
在本章中,我们将探讨每个这些钩子,以揭示使用步骤和一些示例,这将帮助你理解如何开发自己的实现。我们的议程包括以下内容:
-
jOOQ 设置
-
jOOQ 配置
-
jOOQ 提供者
-
jOOQ 监听器
-
修改 jOOQ 代码生成过程
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到 github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter18。
jOOQ 设置
jOOQ 携带了一个全面的设置列表 (org.jooq.conf.Settings),旨在涵盖与渲染 SQL 代码相关的最常用用例。这些设置可以通过声明性方式(通过类路径中的 jooq-settings.xml)或通过 setFooSetting() 或 withFooSetting() 等方法程序化地访问,这些方法可以以流畅的方式链接。为了生效,Settings 必须是 org.jooq.Configuration 的一部分,这可以通过多种方式完成,正如你可以在 jOOQ 手册中阅读的那样 www.jooq.org/doc/latest/manual/sql-building/dsl-context/custom-settings/。但在 Spring Boot 应用程序中,你可能会更喜欢以下方法之一:
通过类路径中的 jooq-settings.xml 将全局 Settings 传递给默认的 Configuration(Spring Boot 准备的 DSLContext 将利用这些设置):
<?xml version="1.0" encoding="UTF-8"?>
<settings>
<renderCatalog>false</renderCatalog>
<renderSchema>false</renderSchema>
<!-- more settings added here -->
</settings>
通过 @Bean 将全局 Settings 传递给默认的 Configuration(Spring Boot 准备的 DSLContext 将利用这些设置):
@org.springframework.context.annotation.Configuration
public class JooqConfig {
@Bean
public Settings jooqSettings() {
return new Settings()
.withRenderSchema(Boolean.FALSE) // this is a setting
... // more settings added here
}
...
}
在某个时刻,设置一个新的全局 Settings,从此时开始应用(这是一个全局 Settings,因为我们使用 Configuration#set()):
ctx.configuration().set(new Settings()
.withMaxRows(5)
... // more settings added here
).dsl()
. // some query
将新的全局设置附加到当前的全局 Settings:
ctx.configuration().settings()
.withRenderKeywordCase(RenderKeywordCase.UPPER);
ctx. // some query
你可以在 MySQL 的 GlobalSettings 中练习这些示例。
在某个时刻,设置一个新的局部 Settings,它只应用于当前查询(这是一个局部 Settings,因为我们使用 Configuration#derive()):
ctx.configuration().derive(new Settings()
.withMaxRows(5)
... // more settings added here
).dsl()
. // some query
或者,设置全局/局部设置并将其附加到更多局部设置:
ctx.configuration().settings()
.withRenderMapping(new RenderMapping()
.withSchemata(
new MappedSchema()
.withInput("classicmodels")
.withOutput("classicmodels_test")));
// 'derivedCtx' inherits settings of 'ctx'
DSLContext derivedCtx = ctx.configuration().derive(
ctx.settings() // using here new Settings() will NOT
// inherit 'ctx' settings
.withRenderKeywordCase(RenderKeywordCase.UPPER)).dsl();
你可以在 MySQL 的LocalSettings中练习这个示例。强烈建议你留出一些时间,至少简要地浏览一下 jOOQ 支持的设置列表,请参阅www.jooq.org/javadoc/latest/org.jooq/org/jooq/conf/Settings.html。接下来,让我们谈谈 jOOQ 的Configuration。
jOOQ 配置
org.jooq.Configuration代表了DSLContext的骨干。DSLContext需要Configuration提供的宝贵信息来进行查询渲染和执行。当Configuration利用Settings(正如你刚才看到的)时,它还有许多其他可以指定的配置,如本节中的示例。
默认情况下,Spring Boot 为我们提供了一个基于默认Configuration(可通过ctx.configuration()访问的Configuration)构建的DSLContext,正如你所知,在提供自定义设置和配置时,我们可以通过set()全局地更改此Configuration,或者通过创建一个派生版本通过derive()局部地更改。
但是,在某些场景下,例如,当你构建自定义提供者或监听器时,你更愿意从一开始就构建Configuration以了解你的工件,而不是从DSLContext中提取它。换句话说,当DSLContext构建时,它应该使用现成的Configuration。
在 Spring Boot 2.5.0 之前,这一步需要一点努力,正如你所看到的那样:
@org.springframework.context.annotation.Configuration
public class JooqConfig {
@Bean
@ConditionalOnMissingBean(org.jooq.Configuration.class)
public DefaultConfiguration jooqConfiguration(
JooqProperties properties, DataSource ds,
ConnectionProvider cp, TransactionProvider tp) {
final DefaultConfiguration defaultConfig =
new DefaultConfiguration();
defaultConfig
.set(cp) // must have
.set(properties.determineSqlDialect(ds)) // must have
.set(tp) // for using SpringTransactionProvider
.set(new Settings().withRenderKeywordCase(
RenderKeywordCase.UPPER)); // optional
// more configs ...
return defaultConfig;
}
这是一个从头开始创建的Configuration(实际上是从 jOOQ 内置的DefaultConfiguration创建的),Spring Boot 将使用它来创建DSLContext。至少,我们需要指定一个ConnectionProvider和 SQL 方言。如果我们要使用SpringTransactionProvider作为 jOOQ 事务的默认提供者,那么我们需要像以下代码那样设置它。在完成此最小配置后,你可以继续添加你的设置、提供者、监听器等。你可以在 MySQL 的Before250Config中练习这个示例。
从 2.5.0 版本开始,Spring Boot 通过一个实现名为DefaultConfigurationCustomizer的功能接口的 bean 简化了对 jOOQ 的DefaultConfiguration的自定义。这充当一个回调,可以像以下示例那样使用:
@org.springframework.context.annotation.Configuration
public class JooqConfig
implements DefaultConfigurationCustomizer {
@Override
public void customize(DefaultConfiguration configuration) {
configuration.set(new Settings()
.withRenderKeywordCase(RenderKeywordCase.UPPER));
... // more configs
}
}
这更实用,因为我们只能添加我们需要的。你可以在 MySQL 的After250Config中查看这个示例。接下来,让我们谈谈 jOOQ 提供者。
jOOQ 提供者
jOOQ SPI 公开了一系列提供者,例如TransactionProvider、RecordMapperProvider、ConverterProvider等。它们的总体目标很简单——提供一些 jOOQ 默认提供者没有提供的功能。例如,让我们看看TransactionProvider。
事务提供者
例如,我们知道 jOOQ 事务在 Spring Boot 中由名为SpringTransactionProvider的事务提供者支持(这是 jOOQ 的TransactionProvider的 Spring Boot 内置实现),默认情况下暴露一个无名称(null)的读写事务,传播行为设置为PROPAGATION_NESTED,隔离级别设置为底层数据库的默认隔离级别ISOLATION_DEFAULT。
现在,让我们假设我们实现了一个只通过 jOOQ 事务(因此我们不使用@Transactional)提供报告的应用程序模块。在这样的模块中,我们不希望允许写入,我们希望在单独的新事务中运行每个查询,超时时间为 1 秒,并避免PROPAGATION_REQUIRES_NEW,将隔离级别设置为ISOLATION_READ_COMMITTED,并将超时设置为 1 秒。
要获得此类事务,我们可以实现一个TransactionProvider并覆盖begin()方法,如下面的代码所示:
public class MyTransactionProvider
implements TransactionProvider {
private final PlatformTransactionManager transactionManager;
public MyTransactionProvider(
PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Override
public void begin(TransactionContext context) {
DefaultTransactionDefinition definition =
new DefaultTransactionDefinition(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
definition.setIsolationLevel(
TransactionDefinition.ISOLATION_READ_COMMITTED);
definition.setName("TRANSACTION_" + Math.round(1000));
definition.setReadOnly(true);
definition.setTimeout(1);
TransactionStatus status =
this.transactionManager.getTransaction(definition);
context.transaction(new SpringTransaction(status));
}
...
}
一旦我们有了事务提供者,我们必须在 jOOQ 中对其进行配置。假设我们正在使用 Spring Boot 2.5.0+,并且根据上一节的内容,可以这样操作:
@org.springframework.context.annotation.Configuration
public class JooqConfig
implements DefaultConfigurationCustomizer {
private final PlatformTransactionManager txManager;
public JooqConfig(PlatformTransactionManager txManager) {
this.txManager = txManager;
}
@Override
public void customize(DefaultConfiguration configuration) {
configuration.set(newMyTransactionProvider(txManager));
}
}
你可以在A250MyTransactionProvider中练习这个例子,适用于 MySQL。当你运行应用程序时,你会在控制台注意到创建的事务具有以下坐标:创建新事务,名称为[TRANSACTION_1000],传播行为为 PROPAGATION_REQUIRES_NEW,隔离级别为 ISOLATION_READ_COMMITTED,超时为 timeout_1,只读。
如果你使用的是 2.5.0 之前的 Spring Boot 版本,那么请查看名为B250MyTransactionProvider的应用程序,适用于 MySQL。
当然,你也可以通过DSLContext来配置提供者:
ctx.configuration().set(
new MyTransactionProvider(txManager)).dsl() ...;
或者,你也可以使用以下方法:
ctx.configuration().derive(
new MyTransactionProvider(txManager)).dsl() ...;
现在,让我们考虑另一个通过ConverterProvider解决的问题场景。
ConverterProvider
我们必须投影一些 JSON 函数并将它们分层映射。我们已经知道,在 Spring Boot + jOOQ 组合中这没有问题,因为 jOOQ 可以获取 JSON 并调用 Jackson(Spring Boot 中的默认选择)来相应地映射它。但是,我们不想使用 Jackson;我们想使用 Flexjson (flexjson.sourceforge.net/)。jOOQ 不认识这个库(jOOQ 只能检测到 Jackson 和 Gson 的存在),因此我们需要提供一个转换器,例如org.jooq.ConverterProvider,它使用 Flexjson 来完成这项任务。请花时间检查A,B}250ConverterProvider的源代码,适用于 MySQL。最后,让我们专注于通过RecordMapperProvider解决的问题场景。
RecordMapperProvider
我们有一堆通过 Builder 模式实现的遗留 POJO,我们决定为这些 POJO 编写一些 jOOQ RecordMapper 以映射查询。为了简化使用这些 RecordMapper 的过程,我们还决定编写一个 RecordMapperProvider。基本上,这将负责使用适当的 RecordMapper,而无需我们显式干预。你对如何做到这一点好奇吗?那么请查看 {A,B}250RecordMapperProvider 和 RecordMapperProvider 应用于 MySQL 的应用。主要,这些应用是相同的,但它们使用不同的方法来配置 RecordMapperProvider。
在 ConverterProvider 和 RecordMapperProvider 中,我认为重要的是要提到,这些 替换 了默认行为,它们并不 增强 它。因此,自定义提供者必须确保在无法处理转换/映射时回退到默认实现。
jOOQ 监听器
jOOQ 提供了相当数量的监听器,它们非常灵活且在钩子到 jOOQ 生命周期管理中非常有用,以解决各种任务。让我们“随意”选择强大的 ExecuteListener。
ExecuteListener
例如,你可能会喜欢的一个监听器是 org.jooq.ExecuteListener。这个监听器提供了一组方法,可以将 Query、Routine 或 ResultSet 的生命周期钩子连接到默认的渲染、准备、绑定、执行和检索阶段。实现你自己的监听器的最方便的方法是扩展 jOOQ 默认实现,DefaultExecuteListener。这样,你只需覆盖你想要的方法,并保持与 SPI 进化的同步(然而,在你阅读这本书的时候,这个默认监听器可能已经被移除,并且所有方法现在都是接口上的默认方法)。考虑将此技术应用于任何其他 jOOQ 监听器,因为 jOOQ 为所有监听器都提供了默认实现(主要,对于 FooListener,有一个 DefaultFooListener)。
目前,让我们编写一个 ExecuteListener,它将改变即将执行的渲染 SQL。基本上,我们只想通过添加 /*+ MAX_EXECUTION_TIME(n) */ 指令来修改每一个 MySQL SELECT,这样我们就可以指定查询的超时时间(以毫秒为单位)。jOOQ DSL 允许添加 MySQL/Oracle 风格的提示。 😃 使用 ctx.select(...).hint("/*+ ... */").from(...). 但只有 ExecuteListener 可以在不修改查询本身的情况下修补多个查询。因此,ExecuteListener 提供了回调函数,如 renderStart(ExecuteContext) 和 renderEnd(ExecuteContext),分别是在从 QueryPart 渲染 SQL 之前和之后调用的。一旦我们掌握了控制权,我们就可以依赖 ExecuteContext,它为我们提供了访问底层连接(ExecuteContext.connection())、查询(ExecuteContext.query())、渲染 SQL(ExecuteContext.sql())等的能力。在这个特定的情况下,我们感兴趣的是访问渲染的 SQL 并对其进行修改,因此我们重写了 renderEnd(ExecuteContext) 并按照以下方式调用 ExecuteContext.sql():
public class MyExecuteListener extends
DefaultExecuteListener{
private static final Logger logger =
Logger.getLogger(MyExecuteListener.class.getName());
@Override
public void renderEnd(ExecuteContext ecx) {
if (ecx.configuration().data()
.containsKey("timeout_hint_select") &&
ecx.query() instanceof Select) {
String sql = ecx.sql();
if (sql != null) {
ecx.sql(sql.replace(
"select",
"select " + ecx.configuration().data()
.get("timeout_hint_select")
));
logger.info(() -> {
return "Executing modified query : " + ecx.sql();
});
}
}
}
}
决策块内的代码相当简单:我们只是捕获即将执行的渲染 SQL(即将不久执行的 SQL)并根据需要通过添加 MySQL 指令来修改它。但是,...data().containsKey("timeout_hint_select") 是什么意思呢?主要的是,Configuration 提供了三种方法,它们协同工作,通过 Configuration 传递自定义数据。这些方法是 data(Object key, Object value),它允许我们设置一些自定义数据;data(Object key),它允许我们根据键获取一些自定义数据;以及 data(),它返回整个自定义数据的 Map。因此,在我们的代码中,我们检查当前 Configuration 的自定义数据是否包含一个名为 timeout_hint_select 的键(这是我们选择的名字)。如果存在这样的键,这意味着我们想要将 MySQL 指令(设置为与该键对应的值)添加到当前的 SELECT 中,否则,我们不做任何操作。这段自定义信息被设置为如下:
Configuration derived = ctx.configuration().derive();
derived.data("timeout_hint_select",
"/*+ MAX_EXECUTION_TIME(5) */");
一旦设置了这段自定义数据,我们就可以执行一个 SELECT,它将通过我们的自定义 ExecuteListener 被丰富 MySQL 指令:
derived.dsl().select(...).fetch();
你可以在 A250ExecuteListener 中练习这个例子,针对 MySQL。如果你使用的是 2.5.0 之前的 Spring Boot 版本,那么选择 B250ExecuteListener。还有一个名为 ExecuteListener 的 MySQL 应用程序,它执行相同的功能,但它通过 CallbackExecuteListener(这代表 ExecuteListener – 如果你更喜欢函数式组合,这很有用)来“内联” ExecuteListener:
ctx.configuration().derive(new CallbackExecuteListener()
.onRenderEnd(ecx -> {
...}))
.dsl()
.select(...).fetch();
大多数监听器也有一个函数式组合的方法,可以在之前的代码片段中使用。接下来,让我们谈谈一个名为 ParseListener 的监听器。
jOOQ SQL 解析器和 ParseListener
ParseListener(SQL 解析监听器)是在 jOOQ 3.15 版本中引入的,但在讨论它之前,我们应该先讨论 SQL Parser(org.jooq.Parser)。
SQL 解析器
jOOQ 携带了一个强大且成熟的 Parser API,它能够将任意 SQL 字符串(或其片段)解析为不同的 jOOQ API 元素。例如,我们有 Parser.parseQuery(String sql),它返回包含单个查询的 org.jooq.Query 类型,该查询对应于传递的 sql。
Parser API 的主要功能之一是它可以在两种方言之间充当翻译器。换句话说,我们拥有方言 X 中的 SQL,并且可以通过 SQL Parser 程序化地传递它,以获得为方言 Y 翻译/模拟的 SQL。例如,考虑一个包含大量为 MySQL 方言编写的原生查询的 Spring Data JPA 应用程序,如下所示:
@Query(value = "SELECT c.customer_name as customerName, "
+ "d.address_line_first as addressLineFirst,
d.address_line_second as addressLineSecond "
+ "FROM customer c JOIN customerdetail d "
+ "ON c.customer_number = d.customer_number "
+ "WHERE (NOT d.address_line_first <=>
d.address_line_second)", nativeQuery=true)
List<SimpleCustomer> fetchCustomerNotSameAddress();
管理层决定切换到 PostgreSQL,因此你应该将这些查询迁移到 PostgreSQL 方言,并且你应该在不显著停机的情况下完成迁移。即使你熟悉这两个方言之间的差异,并且没有问题表达它们,你仍然处于时间压力之下。这是一个 jOOQ 可以帮助你节省时间的场景,因为你只需要将你的原生查询传递给 jOOQ Parser,jOOQ 就会为 PostgreSQL 翻译/模拟它们。假设你正在使用由 Hibernate 支持的 Spring Data JPA,那么你只需要添加一个 Hibernate 拦截器,该拦截器会公开即将执行的 SQL 字符串:
@Configuration
public class SqlInspector implements StatementInspector {
@Override
public String inspect(String sql) {
Query query = DSL.using(SQLDialect.POSTGRES)
.parser()
.parseQuery(sql);
if (query != null) {
return query.getSQL();
}
return null; // interpreted as the default SQL string
}
}
5 分钟内完成!这有多酷?!显然,你的同事会问这是哪种魔法,所以你有一个很好的机会向他们介绍 jOOQ。 😃
如果你查看控制台输出,你会看到 Hibernate 报告了以下 SQL 字符串将被用于针对 PostgreSQL 数据库的执行:
SELECT c.customer_name AS customername,
d.address_line_first AS addresslinefirst,
d.address_line_second AS addresslinesecond
FROM customer AS c
JOIN customerdetail AS d
ON c.customer_number = d.customer_number
WHERE NOT (d.address_line_first IS NOT DISTINCT
FROM d.address_line_second)
当然,你可以更改方言,并为任何 jOOQ 支持的方言获取 SQL。现在,你有时间复制 jOOQ 输出并相应地替换你的原生查询,因为应用程序仍然像往常一样运行。最后,只需解耦这个拦截器。你可以在 JPAParser 中练习这个应用程序。
除了 parseQuery(),我们还有 parseName(String sql),它将给定的 sql 解析为 org.jooq.Name;parseField(String sql),它将给定的 sql 解析为 org.jooq.Field;parseCondition(String sql),它将给定的 sql 解析为 org.jooq.Condition;等等。请查看 jOOQ 文档以了解所有方法和它们的变体。
但 jOOQ 通过所谓的 解析连接 功能(也适用于 R2DBC)可以做得更多。基本上,这意味着 SQL 字符串会通过 jOOQ Parser 传递,输出 SQL 可以成为 java.sql.PreparedStatement 或 java.sql.Statement 的来源,这些可以通过这些 JDBC API(executeQuery(String sql))执行。只要 SQL 字符串是通过 JDBC 连接(java.sql.Connection)获得的,就像这个例子中那样,就会发生这种情况:
仅从语法上无法决定它可能具有哪种输入语义:
try (Connection c = DSL.using(url, user, pass)
.configuration()
.set(new Settings()
.withParseDialect(SQLDialect.MYSQL))
.dsl()
.parsingConnection(); // this does the trick
PreparedStatement ps = c.prepareStatement(sql);
) {
...
}
传递给 PreparedStatement 的 sql 代表任何 SQL 字符串。例如,它可以通过 JdbcTemplate、Criteria API、EntityManager 等生成。从 Criteria API 和 EntityManager 中收集 SQL 字符串可能有点棘手(因为它需要 Hibernate 的 AbstractProducedQuery 动作),但你可以在 JPAParsingConnection 中找到完整的解决方案(MySQL)。
除了 Parser API 之外,jOOQ 还通过 Parser CLI(www.jooq.org/doc/latest/manual/sql-building/sql-parser/sql-parser-cli/)和此网站公开了方言之间的翻译器。现在,我们可以谈谈 ParseListener。
SQL 解析监听器
很容易直观地看出,SQL 解析监听器(jOOQ 3.15 中引入的 org.jooq.ParseListener)负责提供钩子,允许更改 jOOQ 解析器的默认行为。
例如,让我们考虑以下 SELECT 语句,它使用了 SQL 的 CONCAT_WS(separator, str1, str2, ...) 函数:
SELECT concat_ws('|', city, address_line_first,
address_line_second, country, territory) AS address
FROM office
这个忽略 NULL 值并使用字符串分隔符/定界符来分隔所有连接到结果字符串中的参数的可变函数在 MySQL、PostgreSQL 和 SQL Server 中是原生支持的,但在 Oracle 中不受支持。此外,jOOQ(至少直到版本 3.16.4)也不支持它。在我们的查询中使用它的方法之一是使用纯 SQL,如下所示:
ctx.resultQuery("SELECT concat_ws('|', city,
address_line_first, address_line_second, country, territory)
AS address FROM office").fetch();
但是,如果我们尝试在 Oracle 上执行此查询,它将不会工作,因为 Oracle 不支持它,并且 jOOQ 也不在 Oracle 语法中模拟它。一个解决方案是实现我们自己的 ParseListener 来模拟 CONCAT_WS() 的效果。例如,以下 ParseListener 通过 NVL2() 函数实现了这一点(请阅读代码中的所有注释,以便熟悉此 API):
public class MyParseListener extends DefaultParseListener {
@Override
public Field parseField(ParseContext pcx) {
if (pcx.parseFunctionNameIf("CONCAT_WS")) {
pcx.parse('(');
String separator = pcx.parseStringLiteral();
pcx.parse(',');
// extract the variadic list of fields
List<Field<?>> fields = pcx.parseList(",",
c -> c.parseField());
pcx.parse(')'); // the function CONCAT_WS() was parsed
...
解析后,我们准备 Oracle 模拟:
...
// prepare the Oracle emulation
return CustomField.of("", SQLDataType.VARCHAR, f -> {
switch (f.family()) {
case ORACLE -> {
Field result = inline("");
for (Field<?> field : fields) {
result = result.concat(DSL.nvl2(field,
inline(separator).concat(
field.coerce(String.class)), field));
}
f.visit(result); // visit this QueryPart
}
// case other dialect ...
}
});
}
// pass control to jOOQ
return null;
}
}
为了使代码简单且简短,我们考虑了一些假设。主要的是,分隔符和字符串字面量应该用单引号括起来,分隔符本身是单个字符,并且分隔符之后至少应该有一个参数。
这次,当我们这样做时:
String sql = ctx.configuration().derive(SQLDialect.ORACLE)
.dsl()
.render(ctx.parser().parseQuery("""
SELECT concat_ws('|', city, address_line_first,
address_line_second, country, territory) AS address
FROM office"""));
ctx.resultQuery(sql).fetch();
我们的解析器(随后是 jOOQ 解析器)生成与 Oracle 语法兼容的 SQL:
SELECT ((((('' || nvl2(CITY, ('|' || CITY), CITY)) ||
nvl2(ADDRESS_LINE_FIRST, ('|' || ADDRESS_LINE_FIRST),
ADDRESS_LINE_FIRST)) ||
nvl2(ADDRESS_LINE_SECOND, ('|' || ADDRESS_LINE_SECOND),
ADDRESS_LINE_SECOND)) ||
nvl2(COUNTRY, ('|' || COUNTRY), COUNTRY)) ||
nvl2(TERRITORY, ('|' || TERRITORY), TERRITORY)) ADDRESS
FROM OFFICE
你可以在 A250ParseListener 中练习这个示例(适用于 Spring Boot 2.5.0+ 的 Oracle),以及在 B250ParseListener 中练习这个示例(适用于 Spring Boot 2.5.0 之前的 Oracle)。除了解析字段(Field)外,ParseListener 还可以解析表(通过 parseTable() 解析 org.jooq.Table)和条件(通过 parseCondition() 解析 org.jooq.Condition)。
如果你更喜欢函数式组合,那么请查看 CallbackParseListener。接下来,让我们快速概述其他 jOOQ 监听器。
记录监听器
通过 jOOQ RecordListener实现,我们可以在UpdatableRecord事件(如插入、更新、删除、存储和刷新)期间添加自定义行为(如果您不熟悉UpdatableRecord,请考虑第三章,jOOQ 核心概念))。
对于RecordListener监听的每个事件,我们都有一个eventStart()和eventEnd()方法。eventStart()是在事件发生之前调用的回调,而eventEnd()回调是在事件发生后调用的。
例如,让我们考虑每次插入EmployeeRecord时,我们都有一个生成主键EMPLOYEE_NUMBER的算法。接下来,EXTENSION字段始终为xEmployee_number类型(例如,如果EMPLOYEE_NUMBER是9887,则EXTENSION是x9887)。由于我们不希望让人们手动完成这项任务,我们可以通过RecordListener轻松自动化此过程,如下所示:
public class MyRecordListener extends DefaultRecordListener {
@Override
public void insertStart(RecordContext rcx) {
if (rcx.record() instanceof EmployeeRecord employee) {
// call the secret algorithm that produces the PK
long secretNumber = (long) (10000 * Math.random());
employee.setEmployeeNumber(secretNumber);
employee.setExtension("x" + secretNumber);
}
}
}
可能值得提及的是,RecordListener不适用于普通 DML 语句(更不用说纯 SQL 模板了)。人们常常认为他们可以在其中添加一些安全内容,然后就会被绕过。它实际上仅适用于TableRecord/UpdatableRecord类型。从 jOOQ 3.16 开始,许多目前使用RecordListener解决的问题可能更适合使用VisitListener来解决,一旦新的查询对象模型到位,它将变得更加强大(blog.jooq.org/traversing-jooq-expression-trees-with-the-new-traverser-api/)。在 jOOQ 3.16 中,它可能还没有准备好执行此任务,但可能在 jOOQ 3.17 中实现。
您可以在 MySQL 的{A,B}250RecordListener1中练习此应用程序。此外,您还可以找到 MySQL 的{A,B}250RecordListener2应用程序,该应用程序通过重写insertEnd()来自动在EMPLOYEE_STATUS中插入一行,基于插入的EmployeeRecord:
@Override
public void insertEnd(RecordContext rcx) {
if (rcx.record() instanceof EmployeeRecord employee) {
EmployeeStatusRecord status =
rcx.dsl().newRecord(EMPLOYEE_STATUS);
status.setEmployeeNumber(employee.getEmployeeNumber());
status.setStatus("REGULAR");
status.setAcquiredDate(LocalDate.now());
status.insert();
}
}
如果您更喜欢函数式组合,那么请查看CallbackRecordListener。
DiagnosticsListener
DiagnosticsListener从 jOOQ 3.11 开始提供,并且非常适合您想要检测数据库交互中的低效场景。此监听器可以在不同的级别上操作,例如 jOOQ、JDBC 和 SQL 级别。
主要来说,此监听器公开了一系列回调(每个回调针对它检测到的问题)。例如,我们有repeatedStatements()用于检测 N+1 问题,tooManyColumnsFetched()用于检测ResultSet是否检索了比必要的更多列,tooManyRowsFetched()用于检测ResultSet是否检索了比必要的更多行,等等(您可以在文档中找到所有这些)。
让我们假设一个运行以下经典 N+1 场景的 Spring Data JPA 应用程序(Productline和Product实体涉及一个懒加载的双向@OneToMany关系):
@Transactional(readOnly = true)
public void fetchProductlinesAndProducts() {
List<Productline> productlines
= productlineRepository.findAll();
for (Productline : productlines) {
List<Product> products = productline.getProducts();
System.out.println("Productline: "
+ productline.getProductLine()
+ " Products: " + products);
}
}
因此,有一个SELECT被触发以获取产品线,对于每个产品线,都有一个SELECT来获取其产品。显然,在性能方面,这并不高效,jOOQ 可以通过自定义的DiagnosticsListener来发出信号,如下所示:
public class MyDiagnosticsListener
extends DefaultDiagnosticsListener {
private static final Logger = ...;
@Override
public void repeatedStatements(DiagnosticsContext dcx) {
log.warning(() ->
"These queries are prone to be a N+1 case: \n"
+ dcx.repeatedStatements());
}
}
现在,之前的 N+1 情况将被记录下来,所以你已经得到了警告!
jOOQ 可以在java.sql.Connection(diagnosticsConnection())或javax.sql.DataSource(diagnosticsDataSource()将java.sql.Connection包装在DataSource中)上进行诊断。正如在解析连接的情况下,这个 JDBC 连接代理了底层连接,因此你必须通过这个代理传递你的 SQL。在一个 Spring Data JPA 应用程序中,你可以快速构建一个依赖于SingleConnectionDataSource的诊断配置文件,就像你在 MySQL 的JPADiagnosticsListener中看到的那样。对于 MySQL 的SDJDBCDiagnosticsListener,情况也是一样的,它包装了一个 Spring Data JDBC 应用程序。jOOQ 手册还有一些很酷的 JDBC 示例,你应该检查一下(www.jooq.org/doc/latest/manual/sql-execution/diagnostics/)。
TransactionListener
如其名所示,TransactionListener提供了干扰事务事件(如开始、提交和回滚)的钩子。对于每个此类事件,都有一个在事件之前调用的eventBegin(),以及一个在事件之后调用的eventEnd()。此外,为了功能组合的目的,还有CallbackTransactionListener。
让我们考虑一个场景,该场景要求我们在每次更新EmployeeRecord后备份数据。通过“备份”,我们理解我们需要在要更新的员工的相应文件中保存一个包含此更新之前数据的INSERT。
TransactionListener不暴露底层 SQL 的信息,因此我们无法从这个监听器内部确定EmployeeRecord是否被更新。但是,我们可以从RecordListener和updateStart()回调中做到这一点。当一个UPDATE发生时,updateStart()会被调用,我们可以检查记录类型。如果是EmployeeRecord,我们可以通过data()将其原始(original())状态存储如下:
@Override
public void updateStart(RecordContext rcx) {
if (rcx.record() instanceof EmployeeRecord) {
EmployeeRecord employee =
(EmployeeRecord) rcx.record().original();
rcx.configuration().data("employee", employee);
}
}
现在,你可能认为,在更新端(updateEnd()),我们可以将EmployeeRecord的原始状态写入适当的文件。但是,事务可以被回滚,在这种情况下,我们也应该从文件中回滚条目。显然,这是很麻烦的。在事务提交之后再修改文件会容易得多,因此当我们确定更新成功后。这就是TransactionListener和commitEnd()变得有用的地方:
public class MyTransactionListener
extends DefaultTransactionListener {
@Override
public void commitEnd(TransactionContext tcx) {
EmployeeRecord employee =
(EmployeeRecord) tcx.configuration().data("employee");
if (employee != null) {
// write to file corresponding to this employee
}
}
}
太酷了!你刚刚看到了如何结合两个监听器来完成一个常见任务。检查 MySQL 中的{A,B}250RecordTransactionListener的源代码。
VisitListener
我们将要简要介绍的最后一个监听器可能是最复杂的一个,即 VisitListener。主要来说,VisitListener 是一个允许我们操作 jOOQ QueryPart(查询部分)和 Clause(子句)的监听器。因此,我们可以访问 QueryPart(通过 visitStart() 和 visitEnd())和 Clause(通过 clauseStart() 和 clauseEnd())。
一个非常简单的例子可能如下所示:我们希望通过 jOOQ DSL (ctx.createOrReplaceView("product_view").as(...).execute()) 创建一些视图,并且我们还想将它们添加到 WITH CHECK OPTION 子句中。由于 jOOQ DSL 不支持这个子句,我们可以通过 VisitListener 来实现,如下所示:
public class MyVisitListener extends DefaultVisitListener {
@Override
public void clauseEnd(VisitContext vcx) {
if (vcx.clause().equals(CREATE_VIEW_AS)) {
vcx.context().formatSeparator()
.sql("WITH CHECK OPTION");
}
}
}
当你可以在 {A,B}250VisitListener 中练习这个简单的例子时,我强烈建议你阅读 jOOQ 博客上的这两篇精彩文章:blog.jooq.org/implementing-client-side-row-level-security-with-jooq/ 和 blog.jooq.org/jooq-internals-pushing-up-sql-fragments/。你将有机会学习很多关于 VisitListener API 的知识。你永远不知道何时会用到它!例如,你可能想实现你的 软删除 机制,为每个查询添加条件,等等。在这些场景中,VisitListener 正是你所需要的!此外,当这本书被编写时,jOOQ 开始添加一个新玩家,称为 查询对象模型(QOM),作为一个公开的 API。这个 API 便于轻松、直观且强大地遍历 jOOQ AST。你不希望错过这篇文章:blog.jooq.org/traversing-jooq-expression-trees-with-the-new-traverser-api/。
接下来,让我们谈谈如何修改 jOOQ 代码生成过程。
修改 jOOQ 代码生成过程
我们已经知道 jOOQ 提供了三个代码生成器(用于 Java、Scala 和 Kotlin)。对于 Java,我们使用 org.jooq.codegen.JavaGenerator,它可以通过一组综合的配置进行声明性(或程序性)定制(或者,通过 <configuration>(Maven)、configurations(Gradle)或 org.jooq.meta.jaxb.Configuration)。但是,有时我们需要更多的控制,换句话说,我们需要一个自定义的生成器实现。
实现自定义生成器
想象一个场景,我们需要一个查询方法,如果它由内置的 jOOQ DAO 提供,那将非常方便。显然,jOOQ 的目标是保持一个薄的 DAO 层,避免由不同类型的查询组合引起的大量方法(不要期望在默认 DAO 中看到 fetchByField1AndField2() 这样的查询方法,因为尝试覆盖所有字段的组合(即使是两个字段)会导致一个重量级的 DAO 层,这很可能不会被充分利用)。
但是,我们可以通过自定义生成器来丰富生成的 DAO。一个重要的方面是,自定义生成器需要一个单独的项目(或模块),它将作为将要使用它的项目的依赖项工作。这是必需的,因为生成器必须在编译时运行,所以实现这一点的办法是将它作为一个依赖项添加。由于我们使用的是多模块 Spring Boot 应用程序,我们可以通过将自定义生成器作为项目的单独模块添加来轻松实现这一点。这非常方便,因为大多数 Spring Boot 生产应用程序都是采用多模块风格开发的。
关于自定义生成器的有效实现,我们必须扩展 Java 生成器org.jooq.codegen.JavaGenerator,并重写默认的空方法generateDaoClassFooter(TableDefinition table, JavaWriter out)。占位符代码如下:
public class CustomJavaGenerator extends JavaGenerator {
@Override
protected void generateDaoClassFooter(
TableDefinition table, JavaWriter out) {
...
}
}
基于此占位符代码,让我们生成额外的 DAO 查询方法。
向所有 DAO 添加查询方法
假设我们想要向所有生成的 DAO 添加查询方法,例如,添加一个限制获取 POJO(记录)数量的方法,如List<POJO> findLimitedTo(Integer value),其中value表示在List中要获取的 POJO 数量。查看以下代码:
01:@Override
02:protected void generateDaoClassFooter(
03: TableDefinition table, JavaWriter out) {
04:
05: final String pType =
06: getStrategy().getFullJavaClassName(table, Mode.POJO);
07:
08: // add a method common to all DAOs
09: out.tab(1).javadoc("Fetch the number of records
10: limited by <code>value</code>");
11: out.tab(1).println("public %s<%s> findLimitedTo(
12: %s value) {", List.class, pType, Integer.class);
13: out.tab(2).println("return ctx().selectFrom(%s)",
14: getStrategy().getFullJavaIdentifier(table));
15: out.tab(3).println(".limit(value)");
16: out.tab(3).println(".fetch(mapper());");
17: out.tab(1).println("}");
18:}
让我们快速看看这里发生了什么:
-
在第 5 行,我们要求 jOOQ 提供与当前
table对应的生成 POJO 的名称,并在我们的查询方法中使用该名称来返回List<POJO>。例如,对于ORDER表,getFullJavaClassName()返回jooq.generated.tables.pojos.Order。 -
在第 9 行,我们生成了一些 Javadoc。
-
在第 11-17 行,我们生成方法签名及其主体。第 14 行使用的
getFullJavaIdentifier()提供了当前表的完全限定名称(例如,jooq.generated.tables.Order.ORDER)。 -
第 13 行使用的
ctx()方法和第 16 行使用的mapper()方法定义在org.jooq.impl.DAOImpl类中。每个生成的 DAO 都扩展了DAOImpl,因此可以访问这些方法。
基于此代码,jOOQ 生成器在每个生成的 DAO 末尾添加了一个方法,如下所示(此方法添加在OrderRepository中):
/**
* Fetch the number of records limited by <code>value</code>
*/
public List<jooq.generated.tables.pojos.Order>
findLimitedTo(Integer value) {
return ctx().selectFrom(jooq.generated.tables.Order.ORDER)
.limit(value)
.fetch(mapper());
}
那么只在某些 DAO 中添加方法呢?
在某些 DAO 中添加查询方法
让我们在OrderRepository DAO 中仅添加一个名为findOrderByStatusAndOrderDate()的查询方法。一个简单快捷的解决方案是通过generateDaoClassFooter()方法的TableDefinition参数检查表名。例如,以下代码仅在对应于ORDER表的 DAO 中添加了findOrderByStatusAndOrderDate()方法:
@Override
protected void generateDaoClassFooter(
TableDefinition table, JavaWriter out) {
final String pType
= getStrategy().getFullJavaClassName(table, Mode.POJO);
// add a method specific to Order DAO
if (table.getName().equals("order")) {
out.println("public %s<%s>
findOrderByStatusAndOrderDate(
%s statusVal, %s orderDateVal) {",
List.class, pType,
String.class, LocalDate.class);
...
}
}
此代码仅在jooq.generated.tables.daos.OrderRepository中生成findOrderByStatusAndOrderDate()方法:
/**
* Fetch orders having status <code>statusVal</code>
* and order date after <code>orderDateVal</code>
*/
public List<jooq.generated.tables.pojos.Order>
findOrderByStatusAndOrderDate(String statusVal,
LocalDate orderDateVal) {
return ctx().selectFrom(jooq.generated.tables.Order.ORDER)
.where(jooq.generated.tables.Order.ORDER.STATUS
.eq(statusVal))
.and(jooq.generated.tables.Order.ORDER.ORDER_DATE
.ge(orderDateVal))
.fetch(mapper());
}
除了table.getName()之外,您还可以通过table.getCatalog()、table.getQualifiedName()、table.getSchema()等方法强制执行之前的条件以获得更多控制。
完整示例可在AddDAOMethods中找到,适用于 MySQL 和 Oracle。
作为额外的好处,如果您需要用相应的接口丰富 jOOQ 生成的 DAO,那么您需要一个自定义生成器,就像名为InterfacesDao的应用程序中为 MySQL 和 Oracle 所做的那样。如果您查看此代码,您将看到一个所谓的自定义生成器策略。接下来,让我们详细说明这个方面。
编写自定义生成器策略
您已经知道如何使用<strategy>(Maven),strategy {}(Gradle),或withStrategy()(程序化)来在 jOOQ 代码生成过程中注入自定义行为,用于命名类、方法、成员等。例如,我们已使用此技术将我们的 DAO 类重命名为 Spring Data JPA 风格。
但是,在代码生成期间覆盖命名方案也可以通过自定义生成器策略来实现。例如,当我们要生成某些方法名时,这很有用,就像我们场景中的以下查询:
ctx.select(concat(EMPLOYEE.FIRST_NAME, inline(" "),
EMPLOYEE.LAST_NAME).as("employee"),
concat(EMPLOYEE.employee().FIRST_NAME, inline(" "),
EMPLOYEE.employee().LAST_NAME).as("reports_to"))
.from(EMPLOYEE)
.where(EMPLOYEE.JOB_TITLE.eq(
EMPLOYEE.employee().JOB_TITLE))
.fetch();
这是一个依赖于employee()导航方法的自我连接。遵循默认的生成器策略,通过具有与表本身相同名称的导航方法(对于EMPLOYEE表,我们有employee()方法)来编写自我连接。
但是,如果您觉得EMPLOYEE.employee()有点令人困惑,并且您更喜欢更有意义的东西,比如EMPLOYEE.reportsTo()(或其它),那么您需要一个自定义生成器策略。这可以通过扩展 jOOQ 的DefaultGeneratorStrategy并覆盖 jOOQ 手册中描述的适当方法来实现:www.jooq.org/doc/latest/manual/code-generation/codegen-generatorstrategy/。
因此,在我们的情况下,我们需要覆盖getJavaMethodName()如下:
public class MyGeneratorStrategy
extends DefaultGeneratorStrategy {
@Override
public String getJavaMethodName(
Definition, Mode mode) {
if (definition.getQualifiedName()
.equals("classicmodels.employee")
&& mode.equals(Mode.DEFAULT)) {
return "reportsTo";
}
return super.getJavaMethodName(definition, mode);
}
}
最后,我们必须按照以下方式设置此自定义生成器策略(这里,对于 Maven,但您很容易直观地了解如何为 Gradle 或程序化地做这件事):
<generator>
<strategy>
<name>
com.classicmodels.strategy.MyGeneratorStrategy
</name>
</strategy>
</generator>
完成!现在,在代码生成之后,您可以像下面这样重写之前的查询(注意reportsTo()方法而不是employee()):
ctx.select(concat(EMPLOYEE.FIRST_NAME, inline(" "),
EMPLOYEE.LAST_NAME).as("employee"),
concat(EMPLOYEE.reportsTo().FIRST_NAME, inline(" "),
EMPLOYEE.reportsTo().LAST_NAME).as("reports_to"))
.from(EMPLOYEE)
.where(EMPLOYEE.JOB_TITLE.eq(gma
EMPLOYEE.reportsTo().JOB_TITLE))
.fetch();
jOOQ Java 默认生成器策略遵循Pascal命名策略,这是 Java 语言中最受欢迎的。但是,除了Pascal命名策略之外,jOOQ 还提供了一个KeepNamesGeneratorStrategy自定义生成器策略,它只是简单地保留名称。此外,您可能还想研究JPrefixGeneratorStrategy,分别的JVMArgsGeneratorStrategy。这些只是一些例子(它们不是 jOOQ 代码生成器的一部分),可以在 GitHub 上找到:github.com/jOOQ/jOOQ/tree/main/jOOQ-codegen/src/main/java/org/jooq/codegen/example。
摘要
在本章中,我们简要介绍了 jOOQ SPI。显然,通过 SPI 解决的问题不是日常任务,需要整体上对底层技术有扎实的知识。但是,由于您已经阅读了本书的前几章,您应该没有问题吸收本章中的知识。当然,使用这个 SPI 来解决实际问题需要更多地研究文档并多加实践。
在下一章中,我们将探讨 jOOQ 应用的日志记录和测试。
第十九章:日志记录和测试
在本章中,我们将从 jOOQ 的角度介绍日志记录和测试。鉴于这些是常识性的概念,我不会解释日志记录和测试是什么,也不会强调它们显而易见的重要性。话虽如此,让我们直接进入本章的议程:
-
jOOQ 日志记录
-
jOOQ 测试
让我们开始吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/jOOQ-Masterclass/tree/master/Chapter19。
jOOQ 日志记录
默认情况下,你会在代码生成和查询/过程执行期间看到 jOOQ 的DEBUG级别日志。例如,在常规SELECT执行期间,jOOQ 记录查询 SQL 字符串(带有和没有绑定值),获取结果集的前 5 条记录作为格式良好的表格,以及结果集的大小设置,如图下所示:

图 19.1 – 默认 jOOQ SELECT 执行日志
此图揭示了 jOOQ 日志记录的一些重要方面。首先,jOOQ 记录器命名为org.jooq.tools.LoggerListener,代表在第十八章,jOOQ SPI(提供者和监听器)中介绍的ExecuteListener SPI 的实现。在底层,LoggerListener使用一个内部抽象(org.jooq.tools.JooqLogger),尝试与任何著名的记录器,sl4j,log4j,或 Java 日志 API(java.util.logging)交互。因此,如果你的应用程序使用这些记录器中的任何一个,jOOQ 会将其挂钩并使用它。
如图中所示,当调用renderEnd()回调时,jOOQ 记录查询 SQL 字符串,当调用resultEnd()回调时,记录获取的结果集。然而,依赖于对底层 JDBC ResultSet的懒(顺序)访问的 jOOQ 方法(即使用Cursor的Iterator的方法 – 例如,ResultQuery.fetchStream()和ResultQuery.collect())不会通过resultStart()和resultEnd()。在这种情况下,只有ResultSet的前五条记录被 jOOQ 缓冲,并且可以通过fetchEnd()中的ExecuteContext.data("org.jooq.tools.LoggerListener.BUFFER")进行日志记录。其余的记录要么丢失,要么被跳过。
如果我们执行一个常规过程或查询是 DML,那么还会涉及其他回调。你好奇想了解更多吗?!那么,你自己研究LoggerListener源代码会很有趣。
jOOQ 在 Spring Boot 中的日志记录 – 默认零配置日志
在 Spring Boot 2.x 中,不提供任何明确的日志配置,我们在控制台看到的是INFO级别的日志。这是因为 Spring Boot 默认的日志功能使用流行的 Logback 日志框架。
主要来说,Spring Boot 日志记录器由 spring-boot-starter-logging 依赖项确定,该依赖项(基于提供的配置或自动配置)激活了支持的任何日志提供程序(java.util.logging、log4j2 和 Logback)。此依赖项可以显式或间接地导入(例如,作为 spring-boot-starter-web` 的依赖项)。
在此上下文中,如果你有一个没有明确日志配置的 Spring Boot 应用程序,则不会记录 jOOQ 消息。然而,如果我们简单地启用 DEBUG 级别(或 TRACE 以实现更详细的日志记录),我们可以利用 jOOQ 日志记录。例如,我们可以在 application.properties 中这样做:
// set DEBUG level globally
logging.level.root=DEBUG
// or, set DEBUG level only for jOOQ
logging.level.org.jooq.tools.LoggerListener=DEBUG
你可以在 SimpleLogging(MySQL)中练习这个示例。
jOOQ 与 Logback/log4j2 的日志记录
如果你已经配置了 Logback(例如,通过 logback-spring.xml),那么你需要添加 jOOQ 日志记录器,如下所示:
…
<!-- SQL execution logging is logged to the
LoggerListener logger at DEBUG level -->
<logger name="org.jooq.tools.LoggerListener"
level="debug" additivity="false">
<appender-ref ref="ConsoleAppender"/>
</logger>
<!-- Other jOOQ related debug log output -->
<logger name="org.jooq" level="debug" additivity="false">
<appender-ref ref="ConsoleAppender"/>
</logger>
…
你可以在 Logback(MySQL)中练习这个示例。如果你更喜欢 log4j2,那么可以考虑 Log4j2 应用程序(MySQL)。jOOQ 日志记录器在 log4j2.xml 中配置。
关闭 jOOQ 日志记录
通过 set/withExecuteLogging() 设置可以开启/关闭 jOOQ 日志记录。例如,以下查询将不会被记录:
ctx.configuration().derive(
new Settings().withExecuteLogging(Boolean.FALSE))
.dsl()
.select(PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR)
.from(PRODUCT).fetch();
你可以在 TurnOffLogging(MySQL)中练习这个示例。请注意,此设置不会影响 jOOQ 代码生成器的日志记录。该日志记录是通过 <logging>LEVEL</logging>(Maven)、logging = 'LEVEL'(Gradle)或 .withLogging(Logging.LEVEL)(程序化)配置的。LEVEL 可以是 TRACE、DEBUG、INFO、WARN、ERROR 和 FATAL 中的任何一个。以下是通过 Maven 设置 WARN 级别的示例——记录所有大于或等于 WARN 级别的日志:
<configuration xmlns="...">
<logging>WARN</logging>
</configuration>
你可以在 GenCodeLogging(MySQL)中练习这个示例。
在本节的第二部分,我们将处理一系列示例,这些示例应该帮助你熟悉自定义 jOOQ 日志记录的不同技术。基于这些示例,你应该能够解决你的场景。由于这些只是示例,它们不会涵盖所有可能的案例,这在你的实际场景中值得记住。
自定义结果集日志记录
默认情况下,jOOQ 会截断记录的日志结果集为五条记录。然而,我们可以通过 format(int size) 容易地记录整个结果集,如下所示:
private static final Logger log =
LoggerFactory.getLogger(...);
var result = ctx.select(...)...
.fetch();
log.debug("Result set:\n" + result.format
(result.size()));
对于每个查询(不包括依赖于对底层 JDBC ResultSet 的懒加载、顺序访问的查询),是否考虑记录整个结果集呢?此外,假设我们计划记录行号,如下图所示:

图 19.2 – 自定义结果集日志记录
实现这一目标的一种方法是在 ExecuteListener 中编写自定义日志记录器,并重写 resultEnd() 方法:
public class MyLoggerListener extends DefaultExecuteListener {
private static final JooqLogger log =
JooqLogger.getLogger(LoggerListener.class);
@Override
public void resultEnd(ExecuteContext ctx) {
Result<?> result = ctx.result();
if (result != null) {
logMultiline("Total Fetched result",
result.format(), Level.FINE, result.size());
log.debug("Total fetched row(s)", result.size());
}
}
// inspired from jOOQ source code
private void logMultiline(String comment,
String message, Level level, int size) {
// check the bundled code
}
}
你可以在 LogAllRS(MySQL)中练习这个示例。
自定义绑定参数日志记录
如果我们将日志级别切换到 TRACE (logging.level.root=TRACE),那么我们将得到更详细的 jOOQ 日志。例如,绑定参数作为单独的列表进行记录,如下例所示:
Binding variable 1 : 5000 (integer /* java.lang.Integer */)
Binding variable 2 : 223113 (bigint /* java.lang.Long */)
...
挑战自己将此列表定制以呈现不同的外观,并记录在 DEBUG 级别。你可以在 LogBind for MySQL 中找到一些灵感,它记录了如下绑定:
... : [1] as integer /* java.lang.Integer */ - [5000]
... : [vintageCars] as bigint /* java.lang.Long */ - [223113]
...
将日志绑定作为一个格式良好的表格如何?我期待看到你的代码。
自定义日志调用顺序
假设我们计划丰富 jOOQ 日志以记录图表,如下所示:

图 19.3 – 记录图表
此图表仅记录包含 PRODUCT.PRODUCT_ID(在图表的 X 轴上表示 – 类别)和 PRODUCT.BUY_PRICE(在图表的 Y 轴上表示 – 值)的 SELECT 语句。此外,我们不考虑依赖于底层 JDBC ResultSet 的懒加载顺序访问的查询,例如 ctx.selectFrom(PRODUCT).collect(Collectors.toList());。在这种情况下,jOOQ 仅缓冲记录前五条记录以进行日志记录,因此,在大多数情况下,图表将是不相关的。
第一步是编写一个自定义的 ExecuteListener(我们的日志记录器)并重写 resultEnd() 方法——在从 ResultSet 获取一组记录之后调用。在这个方法中,我们搜索 PRODUCT.PRODUCT_ID 和 PRODUCT.BUY_PRICE,如果我们找到它们,那么我们使用 jOOQ 的 ChartFormat API,如下所示:
@Override
public void resultEnd(ExecuteContext ecx) {
if (ecx.query() != null && ecx.query() instanceof Select) {
Result<?> result = ecx.result();
if (result != null && !result.isEmpty()) {
final int x = result.indexOf(PRODUCT.PRODUCT_ID);
final int y = result.indexOf(PRODUCT.BUY_PRICE);
if (x != -1 && y != -1) {
ChartFormat cf = new ChartFormat()
.category(x)
.values(y)
.shades('x');
String[] chart = result.formatChart(cf).split("\n");
log.debug("Start Chart", "");
for (int i = 0; i < chart.length; i++) {
log.debug("", chart[i]);
}
log.debug("End Chart", "");
} else {
log.debug("Chart", "The chart cannot be
constructed (missing data)");
}
}
}
}
我们还需要另一件事。目前,我们的 resultEnd() 在 jOOQ 的 LoggerListener.resultEnd() 被调用之后被调用,这意味着我们的图表是在结果集之后被记录的。然而,如果你看之前的图,你可以看到我们的图表是在结果集之前被记录的。这可以通过反转 fooEnd() 方法的调用顺序来实现:
configuration.settings()
.withExecuteListenerEndInvocationOrder(
InvocationOrder.REVERSE);
因此,默认情况下,只要 jOOQ 日志记录器被启用,我们的日志记录器(重写的 fooStart() 和 fooEnd() 方法)将在默认日志记录器的对应方法之后被调用(LoggingLogger)。但是,我们可以通过两个设置来反转默认顺序:withExecuteListenerStartInvocationOrder() 用于 fooStart() 方法,withExecuteListenerEndInvocationOrder() 用于 fooEnd() 方法。在我们的情况下,在反转之后,我们的 resultEnd() 在 LoggingLogger.resultEnd() 被调用之前被调用,这就是我们如何将图表插入正确位置的方法。你可以在 ReverseLog for MySQL 中练习这个示例。
将 jOOQ 日志包装到自定义文本中
假设我们计划将每个查询/例程的默认日志记录包装到一些自定义文本中,如下图所示:

图 19.4 – 将 jOOQ 日志包装到自定义文本中
在检查 WrapLog for MySQL 中的潜在解决方案之前,考虑挑战自己解决这个问题。
过滤 jOOQ 日志
有时,我们希望对记录的内容非常挑剔。例如,假设只有INSERT和DELETE语句的 SQL 字符串应该被记录。因此,在我们关闭 jOOQ 默认记录器后,我们设置我们的记录器,它应该能够隔离INSERT和DELETE语句与其他查询。一个简单的方法是应用一个简单的检查,例如(query instanceof Insert || query instanceof Delete),其中query由ExecuteContext.query()提供。然而,在纯 SQL 或包含INSERT和DELETE语句的批处理的情况下,这不会起作用。具体到这些情况,我们可以对通过ExecuteContext传递给renderEnd()的 SQL 字符串应用正则表达式,例如"^(?i:(INSERT|DELETE).*)$"。虽然你可以在FilterLog的 MySQL 代码行中找到这些词的具体实现,但让我们关注另一个场景。
假设我们计划只记录包含一组给定表的常规SELECT、INSERT、UPDATE和DELETE语句(纯 SQL、批处理和例程完全不记录)。例如,我们可以方便地通过data()传递所需的表,如下所示:
ctx.data().put(EMPLOYEE.getQualifiedName(), "");
ctx.data().put(SALE.getQualifiedName(), "");
因此,如果一个查询引用了EMPLOYEE和SALE表,那么只有在这种情况下,它才应该被记录。这次,依赖于正则表达式可能会有些复杂和冒险。更合适的是依赖于一个VisitListener,它允许我们以稳健的方式检查 AST 并提取当前查询的引用表。每个QueryPart都会通过VisitListener,因此我们可以检查其类型并相应地收集:
private static class TablesExtractor
extends DefaultVisitListener {
@Override
public void visitEnd(VisitContext vcx) {
if (vcx.renderContext() != null) {
if (vcx.queryPart() instanceof Table) {
Table<?> t = (Table<?>) vcx.queryPart();
vcx.configuration().data()
.putIfAbsent(t.getQualifiedName(), "");
}
}
}
}
当VisitListener完成其执行时,我们已遍历所有QueryPart,并且收集了当前查询中涉及的所有表,因此我们可以将这些表与给定的表进行比较,并决定是否记录当前查询。请注意,我们的VisitListener已被声明为private static class,因为我们将其内部用于我们的ExecuteListener(我们的记录器),它负责协调记录过程。更确切地说,在适当的时刻,我们将这个VisitListener添加到从ExecuteContext配置派生出来的配置中,该配置传递给我们的ExecuteListener。因此,这个VisitListener并没有添加到用于执行查询的DSLContext配置中。
我们记录器(ExecuteListener)的相关部分如下所示:
public class MyLoggerListener extends DefaultExecuteListener {
...
@Override
public void renderEnd(ExecuteContext ecx) {
if (ecx.query() != null &&
!ecx.configuration().data().isEmpty()) {
...
Configuration configuration = ecx.configuration()
.deriveAppending(new TablesExtractor());
...
if (configuration.data().keySet().containsAll(tables)) {
...
}
...
}
查看高亮代码。deriveAppending()方法从这个配置(通过“这个配置”,我们理解是当前ExecuteContext的配置,它是自动从DSLContext的配置派生出来的)创建一个派生Configuration,并附加了访问监听器。实际上,这个VisitListener是通过VisitListenerProvider插入到Configuration中的,VisitListenerProvider负责为每个渲染生命周期创建一个新的监听器实例。
然而,这有什么意义呢?简而言之,这全部关乎性能和作用域(org.jooq.Scope)。VisitListener被频繁调用;因此,它可能会对渲染性能产生影响。所以,为了最小化其使用,我们确保它只在我们日志记录器的适当条件下使用。此外,VisitListener应将正在渲染的表列表存储在日志记录器可访问的地方。由于我们选择依赖于data()映射,我们必须确保日志记录器和VisitListener可以访问它。通过通过deriveAppending()将VisitListener附加到日志记录器,我们也附加了它的Scope,这样data()映射就可以从两个地方访问。这样,我们可以在作用域的整个生命周期内,在日志记录器和VisitContext之间共享自定义数据。
你可以在FilterVisitLog(用于 MySQL)中练习这个示例。好吧,这就是关于日志的全部内容。接下来,让我们谈谈测试。
jOOQ 测试
完成 jOOQ 测试可以通过几种方式,但我们可以立即强调,不那么吸引人的选项依赖于模拟 jOOQ API,而最佳选项依赖于编写针对生产数据库(或至少是内存数据库)的集成测试。让我们从仅适用于简单情况的选项开始,即模拟 jOOQ API。
模拟 jOOQ API
虽然模拟 JDBC API 可能非常困难,但 jOOQ 解决了这个难题,并通过org.jooq.tools.jdbc暴露了一个简单的模拟 API。这个 API 的巅峰代表是MockConnection(用于模拟数据库连接)和MockDataProvider(用于模拟查询执行)。假设使用 jUnit 5,我们可以这样模拟一个连接:
public class ClassicmodelsTest {
public static DSLContext ctx;
@BeforeAll
public static void setup() {
// Initialise your data provider
MockDataProvider provider = new ClassicmodelsMockProvider();
MockConnection connection = new MockConnection(provider);
// Pass the mock connection to a jOOQ DSLContext
ClassicmodelsTest.ctx = DSL.using(
connection, SQLDialect.MYSQL);
// Optionally, you may want to disable jOOQ logging
ClassicmodelsTest.ctx.configuration().settings()
.withExecuteLogging(Boolean.FALSE);
}
// add tests here
}
在编写测试之前,我们必须将ClassicmodelsMockProvider准备为MockDataProvider的实现,它覆盖了execute()方法。此方法返回一个MockResult数组(每个MockResult代表一个模拟结果)。一个可能的实现可能如下所示:
public class ClassicmodelsMockProvider
implements MockDataProvider {
private static final String ACCEPTED_SQL =
"(SELECT|UPDATE|INSERT|DELETE).*";
...
@Override
public MockResult[] execute(MockExecuteContext mex)
throws SQLException {
// The DSLContext can be used to create
// org.jooq.Result and org.jooq.Record objects
DSLContext ctx = DSL.using(SQLDialect.MYSQL);
// So, here we can have maximum 3 results
MockResult[] mock = new MockResult[3];
// The execute context contains SQL string(s),
// bind values, and other meta-data
String sql = mex.sql();
// Exceptions are propagated through the JDBC and jOOQ APIs
if (!sql.toUpperCase().matches(ACCEPTED_SQL)) {
throw new SQLException("Statement not supported: " + sql);
}
// From this point forward, you decide, whether any given
// statement returns results, and how many
...
}
现在,我们准备出发!首先,我们可以编写一个测试。以下是一个示例:
@Test
public void sampleTest() {
Result<Record2<Long, String>> result =
ctx.select(PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_NAME)
.from(PRODUCT)
.where(PRODUCT.PRODUCT_ID.eq(1L))
.fetch();
assertThat(result, hasSize(equalTo(1)));
assertThat(result.getValue(0, PRODUCT.PRODUCT_ID),
is(equalTo(1L)));
assertThat(result.getValue(0, PRODUCT.PRODUCT_NAME),
is(equalTo("2002 Suzuki XREO")));
}
模拟此行为的代码添加在ClassicmodelsMockProvider中:
private static final String SELECT_ONE_RESULT_ONE_RECORD =
"select ... where `classicmodels`.`product`.`product_id`=?";
...
} else if (sql.equals(SELECT_ONE_RESULT_ONE_RECORD)) {
Result<Record2<Long, String>> result
= ctx.newResult(PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_NAME);
result.add(
ctx.newRecord(PRODUCT.PRODUCT_ID, PRODUCT.PRODUCT_NAME)
.values(1L, "2002 Suzuki XREO"));
mock[0] = new MockResult(-1, result);
}
MockResult构造函数的第一个参数代表受影响的行数,其中-1表示行数不适用。在捆绑的代码(Mock 用于 MySQL)中,你可以看到更多示例,包括测试批处理、获取多个结果以及根据绑定决定结果。然而,不要忘记 jOOQ 测试等同于测试数据库交互,因此模拟仅适用于简单情况。不要用它来测试事务、锁定或测试整个数据库!
如果您不相信我,那么请遵循 Lukas Eder 的声明:“仅在某些情况下,模拟方法才适用,这一点不容忽视。人们仍然会尝试使用这个 SPI,因为它看起来很容易实现,而没有考虑到他们即将以最糟糕的方式实现一个完整的 DBMS。我向许多用户解释过这一点 3-4 次:‘你即将实现一个完整的 DBMS’而他们不断问我:‘为什么 jOOQ ‘只是’ 在模拟时执行这个查询?’ – ‘嗯,jOOQ ‘不是’ 一个 DBMS,但它允许你假装你可以使用模拟 SPI 来编写一个。’ 他们一次又一次地继续问。很难想象这有什么棘手的地方,但尽管它有助于 SEO(人们想要解决这个问题,然后发现 jOOQ),我仍然为一些开发者走上了这条道路……尽管如此,在 jOOQ 中测试一些转换和映射集成是非常好的。”
编写集成测试
为 jOOQ 编写集成测试的快速方法就是简单地为生产数据库创建 DSLContext。以下是一个示例:
public class ClassicmodelsIT {
private static DSLContext ctx;
@BeforeAll
public static void setup() {
ctx = DSL.using("jdbc:mysql://localhost:3306/
classicmodels" + "?allowMultiQueries=true", "root", "root");
}
@Test
...
}
然而,这种方法(以 MySQL 的 SimpleTest 为例)非常适合简单场景,这些场景不需要处理事务管理(开始、提交和回滚)。例如,如果您只需要测试您的 SELECT 语句,那么这种方法可能就是您所需要的全部。
使用 SpringBoot @JooqTest
另一方面,在 Spring Boot 中测试 jOOQ 时,通常需要在单独的事务中运行每个集成测试,并在最后回滚,要实现这一点,您可以使用 @JooqTest 注解,如下所示:
@JooqTest
@ActiveProfiles("test") // profile is optional
public class ClassicmodelsIT {
@Autowired
private DSLContext ctx;
// optional, if you need more control of Spring transactions
@Autowired
private TransactionTemplate template;
@Test
...
}
这次,Spring Boot 自动为当前配置文件(当然,使用显式配置文件是可选的,但我在这里添加了它,因为这是 Spring Boot 应用程序中的常见做法)创建 DSLContext,并且自动将每个测试包裹在一个单独的 Spring 事务中,该事务在结束时回滚。在这种情况下,如果您希望某些测试使用 jOOQ 事务,那么请务必通过使用 @Transactional(propagation=Propagation.NEVER) 注解这些测试方法来禁用 Spring 事务。对于 TransactionTemplate 的使用也是如此。您可以在包含多个测试的 JooqTest MySQL 示例中练习这个例子,包括通过 TransactionTemplate 和 jOOQ 事务实现的 jOOQ 乐观锁定。
通过使用 Spring Boot 配置文件,您可以轻松地为测试配置一个与生产数据库(或不是)相同的单独数据库。在 JooqTestDb 中,您有用于生产的 MySQL classicmodels 数据库和用于测试的 MySQL classicmodels_test 数据库(它们都具有相同的模式和数据,并由 Flyway 管理)。
此外,如果你更喜欢在测试结束时被销毁的内存数据库,那么在 JooqTestInMem(MySQL)中,你有一个用于生产的磁盘上的 MySQL classicmodels 数据库和一个用于测试的内存 H2 classicmodels_mem_test 数据库(它们都具有相同的模式和数据,并由 Flyway 管理)。在这两个应用程序中,在你注入由 Spring Boot 准备的 DSLContext 之后,你必须将 jOOQ 指向测试模式 - 例如,对于内存数据库,如下所示:
@JooqTest
@ActiveProfiles("test")
@TestInstance(Lifecycle.PER_CLASS)
public class ClassicmodelsIT {
@Autowired
private DSLContext ctx;
// optional, if you need more control of Spring transactions
@Autowired
private TransactionTemplate template;
@BeforeAll
public void setup() {
ctx.settings()
// .withExecuteLogging(Boolean.FALSE) // optional
.withRenderNameCase(RenderNameCase.UPPER)
.withRenderMapping(new RenderMapping()
.withSchemata(
new MappedSchema().withInput("classicmodels")
.withOutput("PUBLIC")));
}
@Test
...
}
你应该熟悉来自 第十七章,jOOQ 中的多租户 的这项技术。
使用 Testcontainers
Testcontainers (www.testcontainers.org/) 是一个 Java 库,它允许我们在轻量级 Docker 容器中执行 JUnit 测试,这些容器会自动创建和销毁,用于最常见的数据库。因此,为了使用 Testcontainers,你必须安装 Docker。
一旦你安装了 Docker 并在你的 Spring Boot 应用程序中提供了预期的依赖项,你就可以启动一个容器并运行一些测试。这里,我已为 MySQL 执行了此操作:
@JooqTest
@Testcontainers
@ActiveProfiles("test")
public class ClassicmodelsIT {
private static DSLContext ctx;
// optional, if you need more control of Spring transactions
@Autowired
private TransactionTemplate template;
@Container
private static final MySQLContainer sqlContainer =
new MySQLContainer<>("mysql:8.0")
.withDatabaseName("classicmodels")
.withStartupTimeoutSeconds(1800)
.withCommand("--authentication-
policy=mysql_native_password");
@BeforeAll
public static void setup() throws SQLException {
// load into the database the schema and data
Flyway flyway = Flyway.configure()
.dataSource(sqlContainer.getJdbcUrl(),
sqlContainer.getUsername(), sqlContainer.getPassword())
.baselineOnMigrate(true)
.load();
flyway.migrate();
// obtain a connection to MySQL
Connection conn = sqlContainer.createConnection("");
// intialize jOOQ DSLContext
ctx = DSL.using(conn, SQLDialect.MYSQL);
}
// this is optional since is done automatically anyway
@AfterAll
public static void tearDown() {
if (sqlContainer != null) {
if (sqlContainer.isRunning()) {
sqlContainer.stop();
}
}
}
@Test
...
}
注意,我们已经通过 Flyway 填充了测试数据库,但这不是强制性的。你可以使用任何其他专用工具,例如 Commons DbUtils。例如,你可以通过 org.testcontainers.ext.ScriptUtils 来实现,如下所示:
...
var containerDelegate =
new JdbcDatabaseDelegate(sqlContainer, "");
ScriptUtils.runInitScript(containerDelegate,
"integration/migration/V1.1__CreateTest.sql");
ScriptUtils.runInitScript(containerDelegate,
"integration/migration/afterMigrate.sql");
...
就这样!现在,你可以为测试数据库交互生成一个一次性容器。这很可能是测试生产中 jOOQ 应用程序的最受欢迎的方法。你可以在 Testcontainers(MySQL)中实践这个例子。
测试 R2DBC
最后,如果你正在使用 jOOQ R2DBC,那么编写测试相当直接。
在捆绑的代码中,你可以找到三个 MySQL 的示例,如下所示:
-
TestR2DBC 示例:
ConnectionFactory通过ConnectionFactories.get()创建,DSLContext通过ctx = DSL.using(connectionFactory)创建。测试是在生产数据库上执行的。 -
TestR2DBCDb 示例:
ConnectionFactory由 Spring Boot 自动创建,DSLContext作为@Bean创建。测试是在与生产类似(classicmodels)的 MySQL 测试数据库(classicmodels_test)上执行的。 -
TestR2DBCInMem 示例:
ConnectionFactory由 Spring Boot 自动创建,DSLContext作为@Bean创建。测试是在 H2 内存测试数据库(classicmodels_mem_test)上执行的。
摘要
正如你所看到的,jOOQ 对日志记录和测试有坚实的支持,再次证明它是一个成熟的技术,能够满足生产环境中最苛刻的期望。凭借高生产率和低学习曲线,jOOQ 是我首先使用并推荐的项目。我强烈建议你也这样做!


浙公网安备 33010602011771号