跳到主要内容

脱钩操作

在保存关联对象时,会涉及一个重要的概念:脱勾操作。

概念

现看一个例子

数据库已有数据结构用户期望保存的数据结构
+-Food
|
|
|
+-----Meat(忽略子节点)
|
\-----Bread(忽略子节点)
+-Food
|
+-----Drinks(忽略子节点)
|
|
|
\-----Bread(忽略子节点)
  • 对于Bread而言,在新旧数据结构中都存在,对应update操作

  • 对于Drinks而言,在旧数据结构中不存在,但在新数据中存在,对应INSERT操作

  • 对于Meat而言,在旧数据结构中存在,但在新数据中不存在,对应的操作叫做脱勾操作。

脱勾针对两种数据

  • 中间表数据

  • 子表数据

接下来,我们讨论这两种脱勾操作。

信息

在本文中,为了尽可能简化生成的SQL,让我们聚焦于关联本身和脱勾操作

  • 所有被保存对象一律只指定id属性 (短关联)

  • 聚合根对象的保存模式一律明确指定为UPDATE_ONLY (本文采用调用update方法的写法)

脱勾中间表数据

BOOK表和AUTHOR表之间存在多对过关联,中间表是BOOK_AUTHOR_MAPPING

Book.authorsAuthor.books属性都映射了这个多对多关联,且互为镜像。使用其中任何一个都可以达到演示效果,这里我们选取Book.authors

首先查询数据库,看id为3的Book对应了哪些Author对象:

select * 
from book_author_mapping
where book_id = 3;

得到如下查询结果:

book_idauthor_id
31
32

现在,更新此Book对象

sqlClient.update(
Immutables.createBook(draft -> {
draft.setId(3L);
draft.addIntoAuthors(author -> author.setId(2L));
draft.addIntoAuthors(author -> author.setId(5L));
})
);

新旧数据结构对比如下:

数据库已有数据结构用户期望保存的数据结构
+-Book(id=3)
|
+-----Author(id=1)
|
\-----Author(id=2)


+-Book(id=3)
|
|
|
+-----Author(id=2)
|
\-----Author(id=5)

这表示

  • Book-3Author-2之间的关联不变

  • Book-3Author-5之间需要新建关联

  • Book-3Author-1之间的关联需要被删除,这就是脱勾操作

最终生成三条SQL

  1. 查询Book-3对应了哪些Author对象

    select
    AUTHOR_ID
    from BOOK_AUTHOR_MAPPING
    where
    BOOK_ID = ? /* 3 */
  2. 脱勾操作,切断Book-3Author-1之间的关联

    /* highlight-next-line */
    delete from BOOK_AUTHOR_MAPPING
    where
    (BOOK_ID, AUTHOR_ID) in (
    (? /* 3 */, ? /* 1 */)
    )
    信息

    这就是我们期望的中间表脱钩操作

  3. 新建Book-3Author-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为例

  • 静态配置 (默认配置,供绝大部分业务使用)

    Book.java
    @Entity
    public interface Book {

    @OnDissociate(DissociateAction.SET_NULL)
    @Nullable
    @ManyToOne
    BookStore store();

    ...省略其他代码...
    }
  • 运行时覆盖 (仅针对单条保存指令,极少数有特殊需求的业务使用)

    sqlClient
    .getEntities()
    .saveCommand(book)
    .setDissociateAction(BookProps.STORE, DissociateAction.SET_NULL)
    .execute();

示范

首先,查询数据库,查看id为2的BookStore持有哪些Book对象

select * 
from BOOK
where STORE_ID = 2

得到的查询结果为:

IDNAMEEDITIONPRICESTORE_ID
12GraphQL in Action380.002
11GraphQL in Action281.002
10GraphQL in Action180.002

可见,BookStore-2持有Book-10Book-11Book-12

现在,更新此BookStore对象

sqlClient.update(
Immutables.createBookStore(draft -> {
draft.setId(2L);
draft.addIntoBooks(book -> book.setId(9L));
draft.addIntoBooks(book -> book.setId(10L));
})
);

新旧数据结构对比如下:

数据库已有数据结构用户期望保存的数据结构
+-BookStore(id=2)
|
+-----Book(id=10)
|
+-----Book(id=11)
|
\-----Book(id=12)


+-BookStore(id=2)
|
+-----Book(id=10)
|
|
|
|
|
\-----Book(id=9)

这表示

  • BookStore-2Book-10之间的关联不变

  • BookStore-2Book-9之间需要新建关联

  • BookStore-2需要和Book-11Book-12脱勾。

    然而,具体SQL操作未知,由Book.store属性的脱勾模式配置决定

不同的脱勾模式配置,会导致不同的执行逻辑。接下来,我们对NONESET_NULLDELETE三种情况加以讨论。

  • 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 or DissociateAction.DELETE , or use save command's runtime configuration to override it

  • SET_NULL

    该模式下会生成两条SQL

    1. BookStore-2关联Book-9Book-10,对于Book-10而言,相当于没有任何变化。

      update BOOK
      set
      STORE_ID = ? /* 2 */
      where
      ID in (
      ? /* 9 */, ? /* 10 */
      )
    2. 将隶属BookStore-2且id不是9或10的所有书籍 (本例为Book-11Book-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

    1. BookStore-2关联Book-9Book-10,对于Book-10而言,相当于没有任何变化。

      update BOOK
      set
      STORE_ID = ? /* 2 */
      where
      ID in (
      ? /* 9 */, ? /* 10 */
      )
    2. 查询所有隶属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

    3. 在删除Book-11Book-12之前,先清理它们和和Author之间的多对多关联

      delete from BOOK_AUTHOR_MAPPING
      where
      BOOK_ID in (
      ? /* 11 */, ? /* 12 */
      )
      信息

      如果Book类型和其他类型还存在更多的关联,Jimmer都会清理干净。无非就是这个步骤的SQL条数变多而已。

    4. 最终,安全地删除Book-11Book-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-commandjimmer-examples/kotlin/save-command-kt。这些功能仍然存在,无外乎生成SQL语句更多,被隐藏的细节更繁琐而已。