跳到主要内容

脱钩操作

在采用关联模式AssociatedSaveMode.REPLACE保存关联对象时,会涉及一个重要的概念:脱勾操作。

概念

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

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

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

脱勾针对两种关联

  • 中间表关联

    所谓中间表的关联,显然,指的就是本教程中Book.authorsAuthor.books这类关联。

    其脱钩操作非常简单,只是简单地删除中间表的关联而已,关联对象本身不受任何影响。

  • 子表关联

    所谓子表关联,指基于外键 (无论真伪) 关联的逆关联,例如本教程中的BookStore.books

    其脱钩操作非常简单相对复杂。开发人员可以通过配置来控制具体脱钩行为。

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

脱勾中间表关联

在介绍关联对象的REPLACE保存模式时,我们已经展示了这种最简单的脱钩行为。

因此,本文不再赘述。

脱勾子表关联

所谓子表关联,指基于外键 (无论真伪) 关联的逆向关联,例如BookStore.books

其脱钩操作非常简单相对复杂。开发人员可以为外键所对应的属性 (例如这里的Book.store) 配置脱钩模式,来实现不同的脱钩行为。

脱勾模式

子对象脱勾操作有5种模式

模式描述

NONE

(默认)

视全局配置jimmer.default-dissociate-action-checking而定

LAX

脱钩操作不执行任何动作。

  • 如果外键是真的 (请参见真假外键),当父对象被删除时

    • 如果为数据库中的外键配置了级联删除行为(on cascade set nullon delete delete),由数据库来自动清空被脱钩的子对象的外键,或自动删除被脱钩的子对象

      虽然数据库层面的级联修改行为比ORM层面的级联修改行为性能高,但ORM对此毫不知情,在需要缓存一致性的项目中,请慎用

    • 否则,数据库会报告错误,保存指令被终止

  • 如果外键是假的 (请参见真假外键),当父对象被删除时,不会有任何附加行为发生,放任子对象外键的值出现悬挂问题

    即使假外键的值是非法的悬挂值,jimmer的查询也不会出错,查询系统会得到父对象为null的结果,而非因父对象不存在而报错

CHECK不支持脱钩操作,如果数据库中当前父对象拥有需要脱钩的子对象,则抛出异常阻止操作。
SET_NULL把被脱勾的子对象的外键设置为null。使用此模式的前提是子对象的外键关联属性是nullnullable的;否则尝试此配置将会导致异常。
DELETE将被脱勾的子对象删除。

配置脱勾模式有两种方法

  • 在实体上用注解静态配置,静态配置是全局的。

  • 在代码中代码动态配置,动态配置可以覆盖静态配置,但只影响当前保存指令。

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

保存代码

接下来,我们通过保存包含一对多关联BookStore.books的数据结构来讲解各种脱钩模式。

编写保存代码如下

List<BookStore> stores = Arrays.asList(
Immutables.createBookStore(draft -> {
draft.setName("O'REILLY");
draft.addIntoBooks(book -> {
book.setName("Learning GraphQL");
book.setEdition(3);
book.setPrice(new BigDecimal("51.9"));
});
draft.addIntoBooks(book -> {
book.setName("Learning GraphQL");
book.setEdition(4);
book.setPrice(new BigDecimal("43.9"));
});
draft.addIntoBooks(book -> {
book.setName("Effective TypeScript");
book.setEdition(3);
book.setPrice(new BigDecimal("88.9"));
});
draft.addIntoBooks(book -> {
book.setName("Effective TypeScript");
book.setEdition(4);
book.setPrice(new BigDecimal("85.9"));
});
draft.addIntoBooks(book -> {
book.setName("Programming TypeScript");
book.setEdition(3);
book.setPrice(new BigDecimal("48.9"));
});
draft.addIntoBooks(book -> {
book.setName("Programming TypeScript");
book.setEdition(4);
book.setPrice(new BigDecimal("47.9"));
});
}),
Immutables.createBookStore(draft -> {
draft.setName("MANNING");
draft.addIntoBooks(book -> {
book.setName("GraphQL in Action");
book.setEdition(3);
book.setPrice(new BigDecimal("80.9"));
});
draft.addIntoBooks(book -> {
book.setName("GraphQL in Action");
book.setEdition(4);
book.setPrice(new BigDecimal("81.9"));
});
})
);
sqlClient.saveEntities(
stores,
// 也可以不指定次参数,对`save`方法而言,
// AssociatedSaveMode默认为REPLACE
AssociatedSaveMode.REPLACE
);

新旧对象对比如下

数据库现有数据用户期望保存的数据
--+-O'REILLY
|
+---Learn GraphQL(edition = 1)
|
+---Learn GraphQL(edition = 2)
|
+---Learn GraphQL(edition = 3)
|
|
|
+---Effective TypeScript(edition = 1)
|
+---Effective TypeScript(edition = 2)
|
+---Effective TypeScript(edition = 3)
|
|
|
+---Programming TypeScript(edition = 1)
|
+---Programming TypeScript(edition = 2)
|
\---Programming TypeScript(edition = 3)


--+-O'REILLY
|
|
|
|
|
+---Learn GraphQL(edition = 3)
|
+---Learn GraphQL(edition = 4)
|
|
|
|
|
+---Effective TypeScript(edition = 3)
|
+---Effective TypeScript(edition = 4)
|
|
|
|
|
+---Programming TypeScript(edition = 3)
|
\---Programming TypeScript(edition = 4)
--+-MANNING
|
+---GraphQL in Action(edition = 1)
|
+---GraphQL in Action(edition = 2)
|
\---GraphQL in Action(edition = 3)


--+-MANNING
|
|
|
|
|
+---GraphQL in Action(edition = 3)
|
\---GraphQL in Action(edition = 4)
信息

其中,不再需的8个关联对象,即,需要被脱钩的8个对象,被高亮显示。

那么,Jimmer会如何脱钩这些关联对象呢?

其实,不同的脱钩配置会导致不同的脱钩行为。

1. NONE (默认)

NONE的行为并不是固化的,而是视全局配置jimmer.default-dissociate-action-check而定:

  • 如果jimmer.default-dissociate-action-check为true (默认) 或 当前关联所基于的外键是真的 (数据库中存在相应的外键约束,请参见真假外键),视为CHECK

  • 如果jimmer.default-dissociate-action-check为false且当前关联所基于的外键是假的 (数据库中没有相应的外键约束,请参见真假外键),视为LAX

2. LAX

  • 脱钩模式LAX表示脱钩操作什么也不做,。

  • 关联保存模式AssociatedSaveMode.REPLACE表示不再需要的关联关系需要被丢弃。

很明显,两个规则发生了冲突。此时,AssociatedSaveMode.REPLACE更为优先,LAX被无视,最终被视为CHECK

所以,无法通过本文的例子演示LAX。如果要了解LAX的作用,请参见删除指令

3. CHECK

CHECK模式不允许脱钩关联对象。Jimmer会查询是否存在需要被脱钩的关联对象,如果存在,则抛出异常。

脱钩模式的设置,既可以通过实体定义来设置,也可以通过保存指令来设置。

这里,我们通过实体配置来设置Book.store的脱钩模式

Book.java
@Entity
@KeyUniqueConstraint(noMoreUniqueConstraints = true)
public interface Book {

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

...省略其他代码...
}

为了演示更简单的SQL生成,我们假设sqlClienttargetTransferable功能被打开,这是之前已经介绍过的内容,请参见这里

运行前文的保存代码,最终会生成三条SQL

  1. 保存根对象并非本文的讨论重点,默认折叠
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • 假设MANNING存在,现有id为2
    • 假设AMAZON存在,插入后,数据库自动编号新分配的id为100
  2. 保存关联及关联对象也并非本文的讨论重点,默认折叠
    merge into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) key(
    NAME, EDITION
    ) values(
    ?, ?, ?, ?
    )
    /* batch-0: [Learning GraphQL, 3, 51.9, 1] */
    /* batch-1: [Learning GraphQL, 4, 43.9, 1] */
    /* batch-2: [Effective TypeScript, 3, 88.9, 1] */
    /* batch-3: [Effective TypeScript, 4, 85.9, 1] */
    /* batch-4: [Programming TypeScript, 3, 48.9, 1] */
    /* batch-5: [Programming TypeScript, 4, 47.9, 1] */
    /* batch-6: [GraphQL in Action, 3, 80.9, 2] */
    /* batch-7: [GraphQL in Action, 4, 81.9, 2] */
  3. 查询是否有需要脱钩的对象 (如果有,抛出异常组织保持指令)

    select
    tb_1_.ID
    from BOOK tb_1_
    where
    tb_1_.STORE_ID in (
    ? /* 1 */, ? /* 2 */
    )
    and
    (tb_1_.STORE_ID, tb_1_.ID) not in (
    (? /* 1 */, ? /* 3 */),
    (? /* 1 */, ? /* 100 */),
    (? /* 1 */, ? /* 6 */),
    (? /* 1 */, ? /* 101 */),
    (? /* 1 */, ? /* 9 */),
    (? /* 1 */, ? /* 102 */),
    (? /* 2 */, ? /* 12 */),
    (? /* 2 */, ? /* 103 */)
    )
    limit ? /* 1 */

    经过这条SQL判定,被保存的BookStore对象通过关联属性Book.authors可以找到一些即将被脱钩的Book对象。但是,他们不同意被脱钩,抛出如下异常

    Save error caused by the path: "<root>.books": 
    Cannot dissociate child objects
    because the
    dissociation action of the many-to-one property
    "com.yourcommany.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.yourcommany.yourproject.model.Bookstore" 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

4. SET_NULL

SET_NULL模式通过把关联对象的外键属性设置为null来达到脱钩目的。

警告

SET_NULL模式要求基于外键的关联属性 (这里的Book.store) 必须可为null,否则会编译报错

脱钩模式的设置,既可以通过实体定义来设置,也可以通过保存指令来设置。

这里,我们通过实体配置来设置Book.store的脱钩模式

Book.java
@Entity
@KeyUniqueConstraint(noMoreUniqueConstraints = true)
public interface Book {

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

...省略其他代码...
}

为了演示更简单的SQL生成,我们假设sqlClienttargetTransferable功能被打开,这是之前已经介绍过的内容,请参见这里

运行前文的保存代码,最终会生成三条SQL

  1. 保存根对象并非本文的讨论重点,默认折叠
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • 假设MANNING存在,现有id为2
    • 假设AMAZON存在,插入后,数据库自动编号新分配的id为100
  2. 保存关联及关联对象也并非本文的讨论重点,默认折叠
    merge into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) key(
    NAME, EDITION
    ) values(
    ?, ?, ?, ?
    )
    /* batch-0: [Learning GraphQL, 3, 51.9, 1] */
    /* batch-1: [Learning GraphQL, 4, 43.9, 1] */
    /* batch-2: [Effective TypeScript, 3, 88.9, 1] */
    /* batch-3: [Effective TypeScript, 4, 85.9, 1] */
    /* batch-4: [Programming TypeScript, 3, 48.9, 1] */
    /* batch-5: [Programming TypeScript, 4, 47.9, 1] */
    /* batch-6: [GraphQL in Action, 3, 80.9, 2] */
    /* batch-7: [GraphQL in Action, 4, 81.9, 2] */
  3. 将所有被脱钩对象的外键设置为null

    update BOOK
    set STORE_ID = null
    where
    STORE_ID = ?
    and
    not (
    ID = any(?)
    )
    /* batch-0: [1, [3, 100, 6, 101, 9, 102]] */
    /* batch-1: [2, [12, 103]] */

    同时我们能看到,不同数据库的差异巨大

    • H2Postgres采用了批量化的简单SQL

    • MySQL采用了单条复杂SQL

    信息

    这是H2Posgres支持=any(数组)的语法,MySQL不支持。

5. DELETE

DELETE模式通过删除关联对象来达到脱钩目的。

信息

这里保存BookStore,对BookStore.books关联进行脱钩操作,DELETE模式表示期望删除不再需要的Book对象。

然而,Book实体存在更深的关联Book.authors,所以删除Book对象之前需要考虑如何对Author对象进行脱钩。

事实上,如果因脱钩操作而需要被删除的对象还有更深的关联,那么就演化成了递归删除的问题。 该行为在删除指令中有详细阐述,本文不讨论此问题。

因此,为了简化讨论,我们暂时忽略Author实体,假设当前系统只有BookStoreBook两个实体类型。

脱钩模式的设置,既可以通过实体定义来设置,也可以通过保存指令来设置。

这里,我们通过实体配置来设置Book.store的脱钩模式

Book.java
@Entity
@KeyUniqueConstraint(noMoreUniqueConstraints = true)
public interface Book {

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

...省略其他代码...
}

为了演示更简单的SQL生成,我们假设sqlClienttargetTransferable功能被打开,这是之前已经介绍过的内容,请参见这里

运行前文的保存代码,最终会生成三条SQL

  1. 保存根对象并非本文的讨论重点,默认折叠
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • 假设MANNING存在,现有id为2
    • 假设AMAZON存在,插入后,数据库自动编号新分配的id为100
  2. 保存关联及关联对象也并非本文的讨论重点,默认折叠
    merge into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) key(
    NAME, EDITION
    ) values(
    ?, ?, ?, ?
    )
    /* batch-0: [Learning GraphQL, 3, 51.9, 1] */
    /* batch-1: [Learning GraphQL, 4, 43.9, 1] */
    /* batch-2: [Effective TypeScript, 3, 88.9, 1] */
    /* batch-3: [Effective TypeScript, 4, 85.9, 1] */
    /* batch-4: [Programming TypeScript, 3, 48.9, 1] */
    /* batch-5: [Programming TypeScript, 4, 47.9, 1] */
    /* batch-6: [GraphQL in Action, 3, 80.9, 2] */
    /* batch-7: [GraphQL in Action, 4, 81.9, 2] */
  3. 删除所有需要被脱钩的对象

    delete from BOOK
    where
    STORE_ID = ?
    and
    not (
    ID = any(?)
    )
    /* batch-0: [1, [3, 100, 6, 101, 9, 102]] */
    /* batch-1: [2, [12, 103]] */

    同时我们能看到,不同数据库的差异巨大

    • H2Postgres采用了批量化的简单SQL

    • MySQL采用了单条复杂SQL

    信息

    这是H2Posgres支持=any(数组)的语法,MySQL不支持。