跳到主要内容

关联属性

上一篇文档中,我们介绍了标量属性的抓取,在本文中,我们讨论关联属性的抓取。

ORM有两种关联属性:

  • 引用关联:关联到单个对象*(或null)*,属性返回类型为实体对象,用于表达一对一和多对一关联。

    信息

    本文用多对一关联属性Book.store为例。

  • 集合关联:关联到多个对象,属性返回类型为实体集合,用于表达一对多和多对多关联。

    信息

    本文用多对多关联属性Book.autors为例。

抓取只有id的关联对象

不带任何参数抓取关联对象时,得到的关联对象只有id属性。

多对一: Book.store

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));

这里,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

BookTable book = Tables.BOOK_TABLE;

List<Book> books = sqlClient
.createQuery(book)
.select(
book.fetch(
Fetchers.BOOK_FETCHER.
.allScalarFields()
.authors()
)
)
.execute();

这里,authors()表示对多对多关联进行抓取。我们并未为authors()指定任何参数,这表示只抓取关联对象的id属性。

生成两条SQL:

  1. 查询Book对象本身

    select 
    tb_1_.ID,
    tb_1_.NAME,
    tb_1_.EDITION,
    tb_1_.PRICE
    from BOOK as tb_1_
    where tb_1_.EDITION = ?
  2. 根据关联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,也没有使用过滤器(过滤器是后文将会讲解的概念)。

    Jimmmer会对这种情况进行优化,仅仅查询中间表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

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));

这里,store(...)表示对多对一关联进行抓取。我们为store(...)指定参数以表示想要抓取关联对象的除id以外的其他信息。

生成如下两条SQL:

  1. 查询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 = ?
  2. 根据关联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

BookTable book = Tables.BOOK_TABLE;

List<Book> books = sqlClient
.createQuery(book)
.select(
book.fetch(
Fetchers.BOOK_FETCHER.
.allScalarFields()
.authors(
Fetchers.AUTHOR_FETCHER
.allScalarFields()
)
)
)
.execute();

这里,authors(...)表示对多对多关联进行抓取。我们为authors(...)指定参数以表示想要抓取关联对象的除id以外的其他信息。

生成两条SQL:

  1. 查询Book对象本身

    select 
    tb_1_.ID,
    tb_1_.NAME,
    tb_1_.EDITION,
    tb_1_.PRICE
    from BOOK as tb_1_
    where tb_1_.EDITION = ?
  2. 根据关联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)、基于外键的一对一/多对一属性;但不包含一对多、多对多、基于中间表的一对一/多对一关联属性、计算属性和视图属性。

信息

allTableFieldsallScalarFields的基础上,加上了所有外键的一对一/多对一属性,但是查询出的关联对象只有id属性。

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));

输出的结果为:

[
{
"id": 3,
"name": "Learning GraphQL",
"edition": 3,
"price": 51.00,
"store": {
"id": 1
}
}
...省略其他对象
]
信息

某些情况下,select(table.fetch(Fetchers.XXX_FETCHER.allTableFields()))select(table)等价。此时,可以认为后者是前者的简写方式。

二者等价的前提:关联对象不受

影响

等价的简单写法如下

BookTable book = Tables.BOOK_TABLE;
List<Book> list = sqlClient.createQuery(book)
.where(book.edition().eq(3))
.select(book)
.execute();
System.out.println(toJson(list));

关联对象的特殊配置

BatchSize

在一对多/多对多的关联中,如果关联对象的数量很多,将可能对应用程序的性能造成影响,因此提供了一个BatchSize的配置项进行管理。

以下示例没有配置BatchSize:

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));

生成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的设置进行分批切割,如

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();
危险

这里,authors关联的batchSize被设置为2。此配置设置的值太小会导致性能低下,这里仅仅为了演示,实际项目中请不要设置如此小的值。

这样会导致in(?, ?, ?, ?)被切割为两个in(?, ?),抓取关联对象的Sql会比分裂成两条。

  1. 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 (?, ?)
  2. 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中的全局配置。

  1. JSqlClient.getDefaultBatchSize(): 一对一和多对一关联属性的默认batchSize,默认128
  2. JSqlClient.getDefaultListBatchSize(): 一对多和多对多关联属性的默认batchSize,默认16

创建SqlClient时,可以更改全局配置:

  • 使用Spring Boot

    application.yml or application.properties假如如下配置

    jimmer:
    default-batch-size: 256
    default-list-batch-size: 32
  • 使用底层API

    JSqlClient sqlClient = JSqlClient
    .newBuilder()
    .setDefaultBatchSize(256)
    .setDefaultListBatchSize(32)
    ....
    build();
警告

无论是对象抓取器级的batchSize,还是全局级的batchSize,都不要超过1000,因为Oracle数据库中in(...)最多允许1000个值。

关联级分页

对于集合关联,可以在抓取属性时指定limit(limit, offset),即关联级别的分页。

警告

关联级分页和批量加载无法共存,因此,关联级分页必然导致N + 1问题,请谨慎使用此功能!

如果使用了关联级分页,必须把batchSize指定为1,否则会导致异常。此设计的目的在于让开发人员和阅读代码的人很清楚当前代码存在N + 1性能风险。

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();
  • 因关联分页无法解决N + 1问题,生成的SQL比较多
  • 因不同数据库分页查询方法不同,为了简化讨论,这里假设方言使用了H2Dialect
  1. 查询当前Book对象

    select
    tb_1_.ID,
    tb_1_.NAME,
    tb_1_.EDITION,
    tb_1_.PRICE
    from BOOK as tb_1_
    where tb_1_.EDITION = ?
  2. 对第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 ?
  3. 对第2个Book对象的authors集合进行分页查询

    同上,略

  4. 对第3个Book对象的authors集合进行分页查询

    同上,略

  5. 对第4个Book对象的authors集合进行分页查询

    同上,略

属性过滤器

在抓取关联属性时,可以指定过滤器,为关联对象指定过滤条件。

这里,为了对比,我们让查询选取两列,两列都是Book类型。

  • 第一列对象的Book.authors使用过滤器
  • 第二列对象的Book.authors不使用过滤器
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();

生成三条SQL

  1. 查询元组需要的两个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 = ?
  2. 为第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 ?
  3. 为第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"
}
]
}
}
备注

过滤器不仅可以筛选关联对象,还可以排序关联对象,原理类似,本文不做示范

警告
  1. 对于同时满足以下两个条件的关联属性

    • 多对一
    • 不为null

    施加过滤器会导致异常。

  2. 使用了字段级过滤器后,该字段的关联缓存会失效。

    如果不想让关联缓存失效,可以使用支持多视角缓存的全局过滤器。

  3. 有一种实际开发中常见的错误(以Java为例)

    filter(it -> args.getTable().firstName().ilike "a")

    这段代码创建了条件表达式,但是并没有调用args.where。既不调用args.where也不调用args.orderBy的过滤器代码是没有意义的。

    正确的代码是

    filter(it -> args.where(args.getTable().firstName().ilike "a"))。 :::