关联属性
在上一篇文档中,我们介绍了标量属性的抓取,在本文中,我们讨论关联属性的抓取。
ORM有两种关联属性:
-
引用关联:关联到单个对象*(或null)*,属性返回类型为实体对象,用于表达一对一和多对一关联。
信息本文用多对一关联属性
Book.store
为例。 -
集合关联:关联到多个对象,属性返回类型为实体集合,用于表达一对多和多对多关联。
信息本文用多对多关联属性
Book.authors
为例。
抓取只有id的关联对象
不带任何参数抓取关联对象时,得到的关联对象只有id
属性。
多对一: Book.store
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> list = sqlClient.createQuery(book)
.where(book.edition().eq(3))
.select(
book.fetch(
Fetchers.BOOK_FETCHER
.allScalarFields()
.store()
)
)
.execute();
System.out.println(toJson(list));
val books = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
table.fetchBy {
allScalarFields()
store()
}
)
}
.execute()
这里,store()
表示对多对一关联进行抓取。我们并未为store()
指定任何参数,这表示只抓取关联对象的id属性。
生成如下SQL:
select
tb_1_.ID,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE,
tb_1_.STORE_ID
from BOOK tb_1_
where tb_1_.EDITION = ?
因为多对一关联Book.store
基于真实外键,所以当前表BOOK
的外键STORE_ID
就是父对象的id。
由于store()
抓取只有id属性的关联对象,所以,无需额外的SQL查询,即可根据当前数据的外键直接构建只有id属性的父对象。
输出的返回值如下:
[
{
"id": 3,
"name": "Learning GraphQL",
"edition": 3,
"price": 51.00,
"store": {
"id": 1
}
},
...省略其他对象...
]
多对多: Book.authors
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> books = sqlClient
.createQuery(book)
.select(
book.fetch(
Fetchers.BOOK_FETCHER.
.allScalarFields()
.authors()
)
)
.execute();
val books = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
table.fetchBy {
allScalarFields()
authors()
}
)
}
.execute()
这里,authors()
表示对多对多关联进行抓取。我们并未为authors()
指定任何参数,这表示只抓取关联对象的id属性。
生成两条SQL:
-
查询
Book
对象本身select
tb_1_.ID,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE
from BOOK as tb_1_
where tb_1_.EDITION = ? -
根据关联
Book.authors
,为上一步查询的所有根对象查询仅包含id的Author
关联对象select
tb_1_.BOOK_ID, /* batch-map key */
tb_1_.AUTHOR_ID /* batch-map value */
from BOOK_AUTHOR_MAPPING as tb_1_
where tb_1_.BOOK_ID in (?, ?, ?, ?)
这个例子说明了以下问题
-
当前查询仅需要关联对象的id,也没有使用过滤器(过滤器是后文将会讲解的概念)。
Jimmer会对这种情况进行优化,仅仅查询中间表
BOOK_AUTHOR_MAPPING
,不查询AUTHOR
表,因为中间表已经包含了关联对象的id。 -
where tb_1_.BOOK_ID in (?, ?, ?, ?)
是批量查询,这是因为第一条查询返回主对象有4个。Jimmer使用批量查询来解决
N + 1
问题,这点和GraphQL的DataLoader
一样。当一个批次的列表过于太长后,jimmer-sql会对进行分批切割,这会在后文的BatchSize节中讲解。
-
Jimmer通过额外的SQL去查询关联对象,而非在主查询的SQL中使用LEFT JOIN抓取关联对象。
这样设计的目的是为了防止对集合关联进行JOIN导致查询结果重复,因为这种数据重复对聚合根分页查询有毁灭性的破坏效果。
打印的结果如下(原输出是紧凑的,为了方便阅读,这里进行了格式化):
[
{
"id":3,
"name":"Learning GraphQL",
"edition":3,
"price":51.00,
"authors":[
{"id":1},
{"id":2}
]
},
...省略其他对象...
]
抓取复杂的关联对象
在抓取关联属性时,可以指定参数以获取具备更多信息的关联对象
多对一: Book.store
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> list = sqlClient.createQuery(book)
.where(book.edition().eq(3))
.select(
book.fetch(
Fetchers.BOOK_FETCHER
.allScalarFields()
.store(
Fetchers.BOOK_STORE_FETCHER
.allScalarFields()
)
)
)
.execute();
System.out.println(toJson(list));
val books = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
table.fetchBy {
allScalarFields()
store {
allScalarFields()
}
}
)
}
.execute()
这里,store(...)
表示对多对一关联进行抓取。我们为store(...)
指定参数以表示想要抓取关联对象的除id以外的其他信息。
生成如下两条SQL:
-
查询Book对象
select
tb_1_.ID,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE,
tb_1_.STORE_ID
from BOOK tb_1_
where tb_1_.EDITION = ? -
根据关联
Book.store
,为上一步查询的所有根对象查询相对完整的BookStore
关联对象select
tb_1_.ID,
tb_1_.NAME,
tb_1_.WEBSITE
from BOOK_STORE tb_1_
where tb_1_.ID in (?, ?)
where tb_1_.ID in (?, ?)
是批量查询。第一条查询返回主对象有4个,但是外键属性被去重后只有两个值。
输出的返回值如下:
[
{
"id": 3,
"name": "Learning GraphQL",
"edition": 3,
"price": 51.00,
"store": {
"id": 1,
"name": "O'REILLY",
"website": null
}
},
...省略其他对象...
]
多对多: Book.authors
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> books = sqlClient
.createQuery(book)
.select(
book.fetch(
Fetchers.BOOK_FETCHER.
.allScalarFields()
.authors(
Fetchers.AUTHOR_FETCHER
.allScalarFields()
)
)
)
.execute();
val books = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
table.fetchBy {
allScalarFields()
authors {
allScalarFields()
}
}
)
}
.execute()
这里,authors(...)
表示对多对多关联进行抓取。我们为authors(...)
指定参数以表示想要抓取关联对象的除id以外的其他信息。
生成两条SQL:
-
查询
Book
对象本身select
tb_1_.ID,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE
from BOOK as tb_1_
where tb_1_.EDITION = ? -
根据关联
Book.authors
,为上一步查询的所有根对象查询相对完整的Author
关联对象select
/* batch-map key */
tb_2_.BOOK_ID,
/* batch-map value */
tb_1_.ID,
tb_1_.FIRST_NAME,
tb_1_.LAST_NAME,
tb_1_.GENDER
from AUTHOR tb_1_
inner join BOOK_AUTHOR_MAPPING tb_2_
on tb_1_.ID = tb_2_.AUTHOR_ID
where tb_2_.BOOK_ID in (?, ?, ?, ?)
这个例子说明了以下问题
-
要求抓取关联对象的除id以外的其他信息,因此,除了中间表
BOOK_AUTHOR_MAPPING
外,AUTHOR
表也会被查询。 -
where tb_2_.BOOK_ID in (?, ?, ?, ?)
是批量查询,这是因为第一条查询返回主对象有4个。Jimmer使用批量查询来解决
N + 1
问题,这点和GraphQL的DataLoader
一样。当一个批次的列表过于太长后,jimmer-sql会对进行分批切割,这会在后文的BatchSize节中讲解。
-
Jimmer通过额外的SQL去查询关联对象,而非在主查询的SQL中使用LEFT JOIN抓取关联对象。
这样设计的目的是为了防止对集合关联进行JOIN导致查询结果重复,因为这种数据重复对聚合根分页查询有毁灭性的破坏效果。
打印的结果如下(原输出是紧凑的,为了方便阅读,这里进行了格式化):
[
{
"id":3,
"name":"Learning GraphQL",
"edition":3,
"price":51.00,
"authors":[
{
"id": 1,
"firstName": "Eve",
"lastName": "Procello",
"gender": "FEMALE"
},
{
"id": 2,
"firstName": "Alex",
"lastName": "Banks",
"gender": "MALE"
}
]
},
...省略其他对象...
]
抓取单表所有字段
在一些场景下需要抓取数据看表所定义中的所有字段,此时使用allTableFields
。
allTableFields
抓取表定义中的所有属性,包含所有标量属性(这点同allScalarFields
)、基于外键的一对一/多对一属性;但不包含一对多、多对多、基于中间表的一对一/多对一关联属性、计算属性和视图属性。
allTableFields
在allScalarFields
的基础上,加上了所有外键的一对一/多对一属性,但是查询出的关联对象只有id属性。
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> list = sqlClient.createQuery(book)
.where(book.edition().eq(3))
.select(
book.fetch(
Fetchers.BOOK_FETCHER
.allTableFields()
)
)
.execute();
System.out.println(toJson(list));
val bookAllTableFields = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
table.fetchBy {
allTableFields()
}
)
}
.execute()
输出的结果为:
[
{
"id": 3,
"name": "Learning GraphQL",
"edition": 3,
"price": 51.00,
"store": {
"id": 1
}
}
...省略其他对象
]
某些情况下,select(table.fetch(Fetchers.XXX_FETCHER.allTableFields()))
和select(table)
等价。此时,可以认为后者是前者的简写方式。
二者等价的前提:关联对象不受
影响等价的简单写法如下
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> list = sqlClient.createQuery(book)
.where(book.edition().eq(3))
.select(book)
.execute();
System.out.println(toJson(list));
val bookAllScalarFields = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(table)
}
.execute()
关联对象的特殊配置
BatchSize
在一对多/多对多的关联中,如果关联对象的数量很多,将可能对应用程序的性能造成影响,因此提供了一个BatchSize的配置项进行管理。
以下示例没有配置BatchSize:
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> list = sqlClient.createQuery(book)
.where(book.edition().eq(3))
.select(
book.fetch(
Fetchers.BOOK_FETCHER.allScalarFields()
.authors(Fetchers.AUTHOR_FETCHER.allScalarFields())
)
)
.execute();
System.out.println(toJson(list));
val books = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
table.fetchBy {
allTableFields()
authors {
allScalarFields()
}
}
)
}
.execute()
生成SQL如下:
# 1.首先查询book表得到所有BOOK_ID
select
tb_1_.ID,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE
from BOOK tb_1_
where tb_1_.EDITION = ?
# 2.用上一步得到的所有BOOK_ID关联查询author表和中间表
select
tb_2_.BOOK_ID,
tb_1_.ID,
tb_1_.FIRST_NAME,
tb_1_.LAST_NAME,
tb_1_.GENDER
from AUTHOR tb_1_
inner join BOOK_AUTHOR_MAPPING tb_2_ on tb_1_.ID = tb_2_.AUTHOR_ID
where tb_2_.BOOK_ID in (?, ?, ?, ?)
在该sql中,因为没有配置BatchSize所以会使用默认值,有多少个BOOK_ID都会直接使用在第二个SQL查询的in(...)
列表里。
输出结果如下:
[
{
"id": 3,
"name": "Learning GraphQL",
"edition": 3,
"price": 51.00,
"authors": [
{
"id": 1,
"firstName": "Eve",
"lastName": "Procello",
"gender": "FEMALE"
},
{
"id": 2,
"firstName": "Alex",
"lastName": "Banks",
"gender": "MALE"
}
]
},
...省略其他对象
]
上面的例子中,我们看到这样的查询
select
tb_2_.BOOK_ID,
tb_1_.ID,
tb_1_.FIRST_NAME,
tb_1_.LAST_NAME,
tb_1_.GENDER
from AUTHOR tb_1_
inner join BOOK_AUTHOR_MAPPING tb_2_ on tb_1_.ID = tb_2_.AUTHOR_ID
where tb_2_.BOOK_ID in (?, ?, ?, ?)
这里,in
表达式实现了批量查询,解决了N + 1
问题。
如果一个批量太大,就会根据一个叫batchSize
的设置进行分批切割,如
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> list = sqlClient.createQuery(book)
.where(book.edition().eq(3))
.select(
book.fetch(
Fetchers.BOOK_FETCHER.allScalarFields()
.authors(Fetchers.AUTHOR_FETCHER.allScalarFields(), it -> it.batch(2))
)
)
.execute();
val books = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
table.fetchBy {
allScalarFields()
authors({
batch(2)
}) {}
}
)
}
.execute()
这里,authors
关联的batchSize被设置为2。此配置设置的值太小会导致性能低下,这里仅仅为了演示,实际项目中请不要设置如此小的值。
这样会导致in(?, ?, ?, ?)
被切割为两个in(?, ?)
,抓取关联对象的Sql会比分裂成两条。
-
select
tb_2_.BOOK_ID,
tb_1_.ID,
tb_1_.FIRST_NAME,
tb_1_.LAST_NAME,
tb_1_.GENDER
from AUTHOR tb_1_
inner join BOOK_AUTHOR_MAPPING tb_2_ on tb_1_.ID = tb_2_.AUTHOR_ID
where tb_2_.BOOK_ID in (?, ?) -
select
tb_2_.BOOK_ID,
tb_1_.ID,
tb_1_.FIRST_NAME,
tb_1_.LAST_NAME,
tb_1_.GENDER
from AUTHOR tb_1_
inner join BOOK_AUTHOR_MAPPING tb_2_ on tb_1_.ID = tb_2_.AUTHOR_ID
where tb_2_.BOOK_ID in (?, ?)
实际开发中,绝大部分情况都不会这样设置batchSize,而是采用SqlClient中的全局配置。
JSqlClient.getDefaultBatchSize()
: 一对一和多对一关联属性的默认batchSize,默认128JSqlClient.getDefaultListBatchSize()
: 一对多和多对多关联属性的默认batchSize,默认16
创建SqlClient时,可以更改全局配置:
-
使用Spring Boot
为
application.yml
orapplication.properties
假如如下配置jimmer:
default-batch-size: 256
default-list-batch-size: 32 -
使用底层API
- Java
- Kotlin
JSqlClient sqlClient = JSqlClient
.newBuilder()
.setDefaultBatchSize(256)
.setDefaultListBatchSize(32)
....
build();val sqlClient = newKSqlClient {
setDefaultBatchSize(256)
setDefaultListBatchSize(32)
....
}
无论是对象抓取器级的batchSize,还是全局级的batchSize,都不要超过1000,因为Oracle数据库中in(...)
最多允许1000个值。
关联级分页
对于集合关联,可以在抓取属性时指定limit(limit, offset)
,即关联级别的分页。
关联级分页和批量加载无法共存,因此,关联级分页必然导致N + 1
问题,请谨慎使用此功能!
如果使用了关联级分页,必须把batchSize指定为1,否则会导致异常。此设计的目的在于让开发人员和阅读代码的人很清楚当前代码存在N + 1
性能风险。
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Book> books = sqlClient
.createQuery(book)
.select(
book.fetch(
Fetchers.BOOK_FETCHER
.allScalarFields()
.authors(
Fetchers.AUTHOR_FETCHER.allScalarFields(),
it -> it.batch(1).limit(/*limit*/ 10, /*offset*/ 90)
)
)
)
.execute();
val books = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
table.fetchBy {
allScalarFields()
authors({
batch(1)
limit(limit = 90, offset = 10)
}) {
allScalarFields()
}
}
)
}
.execute()
- 因关联分页无法解决
N + 1
问题,生成的SQL比较多 - 因不同数据库分页查询方法不同,为了简化讨论,这里假设方言使用了
H2Dialect
-
查询当前
Book
对象select
tb_1_.ID,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE
from BOOK as tb_1_
where tb_1_.EDITION = ? -
对第1个
Book
对象的authors
集合进行分页查询select
tb_1_.AUTHOR_ID,
tb_3_.FIRST_NAME,
tb_3_.LAST_NAME,
tb_3_.GENDER
from BOOK_AUTHOR_MAPPING as tb_1_
inner join AUTHOR as tb_3_ on tb_1_.AUTHOR_ID = tb_3_.ID
where tb_1_.BOOK_ID = ?
/* highlight-next-line */
limit ? offset ? -
对第2个
Book
对象的authors
集合进行分页查询同上,略
-
对第3个
Book
对象的authors
集合进行分页查询同上,略
-
对第4个
Book
对象的authors
集合进行分页查询同上,略
属性过滤器
在抓取关联属性时,可以指定过滤器,为关联对象指定过滤条件。
这里,为了对比,我们让查询选取两列,两列都是Book
类型。
- 第一列对象的
Book.authors
使用过滤器 - 第二列对象的
Book.authors
不使用过滤器
- Java
- Kotlin
BookTable book = Tables.BOOK_TABLE;
List<Tuple2<Book, Book>> books = sqlClient
.createQuery(book)
.select(
// 第一列
book.fetch(
Fetchers.BOOK_FETCHER
.allScalarFields()
.authors(
Fetchers.AUTHOR_FETCHER.allScalarFields(),
// 使用过滤器
it -> it.filter(args -> {
args.where(args.getTable().firstName().ilike("a"));
})
)
),
// 第二列
book.fetch(
Fetchers.BOOK_FETCHER
.allScalarFields()
.authors(
Fetchers.AUTHOR_FETCHER.allScalarFields()
// 不使用过滤器
)
)
)
.execute();
val tuples: List<Tuple2<Book, Book>> = sqlClient
.createQuery(Book::class) {
where(table.edition.eq(3))
select(
// 第一列
table.fetchBy {
allScalarFields()
authors({
// 使用过滤器
filter {
where(table.firstName ilike "a")
}
}) {
allScalarFields()
}
},
// 第二列
table.fetchBy {
allScalarFields()
authors { // 不使用过滤器
allScalarFields()
}
}
)
}
.execute()
生成三条SQL
-
查询元组需要的两个
Book
对象select
/* For tuple._1 */
tb_1_.ID,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE,
/* For tuple._2 */
tb_1_.ID,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE
from BOOK as tb_1_
where tb_1_.EDITION = ? -
为第1列的4个
Book
对象查询authors
关联属性,使用过滤器select
tb_1_.BOOK_ID,
tb_1_.AUTHOR_ID,
tb_3_.FIRST_NAME,
tb_3_.LAST_NAME,
tb_3_.GENDER
from BOOK_AUTHOR_MAPPING as tb_1_
inner join AUTHOR as tb_3_ on tb_1_.AUTHOR_ID = tb_3_.ID
where
tb_1_.BOOK_ID in (?, ?, ?, ?)
and
/* Use filter here */
/* highlight-next-line */
lower(tb_3_.FIRST_NAME) like ? -
为第2列的4个
Book
对象查询authors
关联属性,不使用过滤器select
tb_1_.BOOK_ID,
tb_1_.AUTHOR_ID,
tb_3_.FIRST_NAME,
tb_3_.LAST_NAME,
tb_3_.GENDER
from BOOK_AUTHOR_MAPPING as tb_1_
inner join AUTHOR as tb_3_ on tb_1_.AUTHOR_ID = tb_3_.ID
where
tb_1_.BOOK_ID in (?, ?, ?, ?)
/* No filter here */
打印的结果如下(原输出是紧凑的,为了方便阅读,这里进行了格式化):
Tuple2{
_1={
"id":3,
"name":"Learning GraphQL",
"edition":3,
"price":51.00,
// 使用了属性级过滤器,得到的集合不完整
"authors":[
{
"id":2,
"firstName":"Alex",
"lastName":"Banks",
"gender":"MALE"
}
]
},
_2={
"id":3,
"name":"Learning GraphQL",
"edition":3,
"price":51.00,
// 未使用属性级过滤器,得到的集合是完整的
"authors":[
{
"id":2,
"firstName":"Alex",
"lastName":"Banks",
"gender":"MALE"
},{
"id":"fd6bb6cf-336d-416c-8005-1ae11a6694b5",
"firstName":"Eve",
"lastName":"Procello",
"gender":"MALE"
}
]
}
}
过滤器不仅可以筛选关联对象,还可以排序关联对象,原理类似,本文不做示范
-
对于同时满足以下两个条件的关联属性
- 多对一
- 不为null
施加过滤器会导致异常。
-
使用了字段级过滤器后,该字段的关联缓存会失效。
如果不想让关联缓存失效,可以使用支持多视角缓存的全局过滤器。
-
有一种实际开发中常见的错误(以Java为例)
filter(it -> args.getTable().firstName().ilike "a")
。这段代码创建了条件表达式,但是并没有调用
args.where
。既不调用args.where
也不调用args.orderBy
的过滤器代码是没有意义的。正确的代码是
filter(it -> args.where(args.getTable().firstName().ilike "a"))
。