脱钩操作
在保存关联对象时,会涉及一个重要的概念:脱勾操作。
概念
现看一个例子
数据库已有数据结构 | 用户期望保存的数据结构 |
---|---|
|
|
-
对于
Bread
而言,在新旧数据结构中都存在,对应update操作 -
对于
Drinks
而言,在旧数据结构中不存在,但在新数据中存在,对应INSERT操作 -
对于
Meat
而言,在旧数据结构中存在,但在新数据中不存在,对应的操作叫做脱勾操作。
脱勾针对两种数据
-
中间表数据
-
子表数据
接下来,我们讨论这两种脱勾操作。
在本文中,为了尽可能简化生成的SQL,让我们聚焦于关联本身和脱勾操作
-
所有被保存对象一律只指定id属性 (短关联)
-
聚合根对象的保存模式一律明确指定为UPDATE_ONLY (本文采用调用update方法的写法)
脱勾中间表数据
BOOK
表和AUTHOR
表之间存在多对过关联,中间表是BOOK_AUTHOR_MAPPING
。
Book.authors
和Author.books
属性都映射了这个多对多关联,且互为镜像。使用其中任何一个都可以达到演示效果,这里我们选取Book.authors
首先查询数据库,看id为3的Book
对应了哪些Author
对象:
select *
from book_author_mapping
where book_id = 3;
得到如下查询结果:
book_id | author_id |
---|---|
3 | 1 |
3 | 2 |
现在,更新此Book
对象
- Java
- Kotlin
sqlClient.update(
Immutables.createBook(draft -> {
draft.setId(3L);
draft.addIntoAuthors(author -> author.setId(2L));
draft.addIntoAuthors(author -> author.setId(5L));
})
);
sqlClient.update(
Book {
id = 3L
authors().addBy { id = 2L }
authors().addBy { id = 3L }
}
)
新旧数据结构对比如下:
数据库已有数据结构 | 用户期望保存的数据结构 |
---|---|
|
|
这表示
-
Book-3
和Author-2
之间的关联不变 -
Book-3
和Author-5
之间需要新建关联 -
Book-3
和Author-1
之间的关联需要被删除,这就是脱勾操作
最终生成三条SQL
-
查询
Book-3
对应了哪些Author
对象select
AUTHOR_ID
from BOOK_AUTHOR_MAPPING
where
BOOK_ID = ? /* 3 */ -
脱勾操作,切断
Book-3
和Author-1
之间的关联/* highlight-next-line */
delete from BOOK_AUTHOR_MAPPING
where
(BOOK_ID, AUTHOR_ID) in (
(? /* 3 */, ? /* 1 */)
)信息这就是我们期望的中间表脱钩操作
-
新建
Book-3
和Author-5
之间的关联insert into BOOK_AUTHOR_MAPPING(
BOOK_ID, AUTHOR_ID
)
values
(? /* 3 */, ? /* 5 */)
脱勾子表数据
此操作针对直接给予外键的关联。
BOOK
表有一个指向BOOK_STORE
表的外键字段STORE_ID
,形成多对一关系。这个多对一关联被映射成了属性Book.store
,反过来的一对多关联被映射成了属性BookStore.books
。
如果数据库中某个父对象 (本例的BookStore
) 持有某些子对象 (本例的Book
),但是用户期望覆盖的父对象不再继续持有某些子对象,将会导致这些子对象被脱勾。
脱勾模式
子对象脱勾操作有5种模式
模式 | 描述 |
---|---|
NONE (默认) | 视全局配置jimmer.default-dissociate-action-checking而定
|
LAX | 该选项只对伪外键有效 (请参见真假外键),否则,会被忽略,同CHECK。 即便存在子对象,也支持脱钩操作。即使发生父对象被删除的情况 (脱钩模式也被删除指令采用),也任由子对象的伪外键发生悬挂问题 (即便伪外键发生悬挂,查询系统也可以正常工作)。 |
CHECK | 如果存在子对象,则不支持脱勾操作,通过抛出异常阻止操作。 |
SET_NULL | 把被脱勾的子对象的外键设置为null。前提是子对象的多对一关联属性是nullable的;否则尝试此配置将会导致异常。 |
DELETE | 将被脱勾的子对象删除。 |
虽然子对象脱勾是由于一对多关联 (或逆向inverse一对一) 导致的 (即,父对象遗弃某些子对象,本例的一对多关联为BookStore.books
),但是脱勾 模式的配置针对逆向的多对一关联 (本例为Book.store
),这样设计的目的是为了保持和数据库DDL外键的级联特性配置的相似性。
对于Jimmer而言,一对多关联一定是双向关联,知道某个一对多关联,一定知道与其互为镜像的多对一关联。所以,此设计没有任何问题。
配置脱勾模式有两种方法
-
在实体上用注解静态配置,静态配置是全局的。
-
在代码中代码动态配置,但动态配置只影响当前保存指令。动态配置可以覆盖静态配置。
以SET_NULL
为例
-
静态配置 (默认配置,供绝大部分业务使用)
- Java
- Kotlin
Book.java@Entity
public interface Book {
@OnDissociate(DissociateAction.SET_NULL)
@Nullable
@ManyToOne
BookStore store();
...省略其他代码...
}Book.kt@Entity
interface Book {
@OnDissociate(DissociateAction.SET_NULL)
@ManyToOne
val store: BookStore?
...省略其他代码...
} -
运行时覆盖 (仅针对单条保存指令,极少数有特殊需求的业务使用)
- Java
- Kotlin
sqlClient
.getEntities()
.saveCommand(book)
.setDissociateAction(BookProps.STORE, DissociateAction.SET_NULL)
.execute();sqlClient.save(book) {
.setDissociateAction(Book::store, DissociateAction.SET_NULL)
}
示范
首先,查询数据库,查看id为2的BookStore
持有哪些Book
对象
select *
from BOOK
where STORE_ID = 2
得到的查询结果为:
ID | NAME | EDITION | PRICE | STORE_ID |
---|---|---|---|---|
12 | GraphQL in Action | 3 | 80.00 | 2 |
11 | GraphQL in Action | 2 | 81.00 | 2 |
10 | GraphQL in Action | 1 | 80.00 | 2 |
可见,BookStore-2
持有Book-10
、Book-11
和Book-12
。
现在,更新此BookStore
对象
- Java
- Kotlin
sqlClient.update(
Immutables.createBookStore(draft -> {
draft.setId(2L);
draft.addIntoBooks(book -> book.setId(9L));
draft.addIntoBooks(book -> book.setId(10L));
})
);
sqlClient.update(
BookStore {
id = 2L
books().addBy { id = 9L }
books().addBy { id = 10L }
}
)
新旧数据结构对比如下:
数据库已有数据结构 | 用户期望保存的数据结构 |
---|---|
|
|
这表示
-
BookStore-2
和Book-10
之间的关联不变 -
BookStore-2
和Book-9
之间需要新建关联 -
BookStore-2
需要和Book-11
、Book-12
脱勾。然而,具体SQL操作未知,由
Book.store
属性的脱勾模式配置决定
不同的脱勾模式配置,会导致不同的执行逻辑。接下来,我们对NONE
、SET_NULL
和DELETE
三种情况加以讨论。
-
NONE
(默认)NONE
表示不支持脱勾操作,以抛出异常的方式阻止操作并引起事务回 滚。所以,上述代码会异常。异常类型为
org.babyfish.jimmer.sql.runtime.SaveException
。异常消息为
Save error caused by the path: "<root>.books": Cannot dissociate child objects because the dissociation action of the many-to-one property "com.yourcompany.yourproject.model.Book.store" is not configured as "set null" or "cascade". There are two ways to resolve this issue: Decorate the many-to-one property "com.yourcompany.yourproject.model.Book.store" by @org.babyfish.jimmer.sql.OnDissociate whose argument is
DissociateAction.SET_NULL
orDissociateAction.DELETE
, or use save command's runtime configuration to override it -
SET_NULL
该模式下会生成两条SQL
-
让
BookStore-2
关联Book-9
和Book-10
,对于Book-10
而言,相当于没有任何变化。update BOOK
set
STORE_ID = ? /* 2 */
where
ID in (
? /* 9 */, ? /* 10 */
) -
将隶属
BookStore-2
且id不是9或10的所有书籍 (本例为Book-11
和Book-11
) 的外键清空。update BOOK
set
STORE_ID = null
where
STORE_ID = ? /* 2 */
and
ID not in ( /* 注意`not` */
? /* 9 */, ? /* 10 */
)信息请注意SQL中的
not
,这就是SET_NULL
模式对子表的脱钩操作。
-
-
DELETE
该模式下会生成4条SQL
-
让
BookStore-2
关联Book-9
和Book-10
,对于Book-10
而言,相当于没有任何变化。update BOOK
set
STORE_ID = ? /* 2 */
where
ID in (
? /* 9 */, ? /* 10 */
) -
查询所有隶属
BookStore-2
且id不是9或10的所有书籍,对于本例而言,查询结果为Book-11
和``Book-12`。select
ID
from BOOK
where
STORE_ID = ? /* 2 */
and
ID not in (
? /* 9 */, ? /* 10 */
)备注请注意SQL中的
not
-
在删除
Book-11
和Book-12
之前,先清理它们和和Author
之间的多对多关联delete from BOOK_AUTHOR_MAPPING
where
BOOK_ID in (
? /* 11 */, ? /* 12 */
)信息如果
Book
类型和其他类型还存在更多的关联,Jimmer都会清理干净。无非就是这个步骤的SQL条数变多而已。 -
最终,安全地删除
Book-11
和Book-12
,完成脱钩操作delete from BOOK
where
ID in (
? /* 11 */, ? /* 12 */
)
提示步骤3和步骤4是一个整体,它们共同组成了脱钩操作。
事实上,这就是后续文档要介绍的删除指令。
-
本文对例子进行了很多简化,比如
-
明确指定聚合根对象的处理模式为
UPDATE_ONLY
(调用update
而非save
) -
关联对象全部是只有id的短关联对象。事实上,你可以随意改变关联对象的格式,比如
-
不指定
id
属性,而指定key
属性 (的另外一种表达方式) -
指定
key
属性以及一些既非id
也非key
的属性 ()
-
本文如此简化的例子,只是为了让保存指令生成的SQL尽可能简单,让读者能快速明白脱钩操作的特性。
至于格式更复杂的关联对象,读者可以自行尝试,也可以直接参考官方例子jimmer-examples/java/save-command或jimmer-examples/kotlin/save-command-kt。这些功能仍然存在,无外乎生成SQL语句更多,被隐藏的细节更繁琐而已。