跳到主要内容

关联对象保存模式

基本概念

之前的文章中中,我们介绍了如何控制聚合根对象的保存模式。

本文将讨论如何控制关联对象的保存模式,关联对象支持如下保存模式:

关联保存模式适用范围接受Wild对象描述
APPEND所有关联无条件地对关联对象进行INSERT操作
APPEND_IF_ABSENT所有关联
  1. 先判断关联对象在数据库中是否存在
  • 如果关联对象的id属性被指定了,按照id判断关联对象在数据库中是否存在
  • 否则,按照key判断关联对象在数据库中是否存在
  1. 根据判断结果执行后续操作
  • 如果关联对象已经存在,忽略操作是么也不做
  • 否则,插入关联对象
备注

Jimmer会尽量利用数据库本身的UPSERT的能力,将两个步骤合并。如果做不到,告诉用户原因

UPDATE所有关联
  • 如果关联对象的id属性被指定了,按照id更新关联对象
  • 否则,按照key更新关联对象
MERGE所有关联
  1. 先判断关联对象在数据库中是否存在
  • 如果关联对象的id属性被指定了,按照id判断关联对象在数据库中是否存在
  • 否则,按照key判断关联对象在数据库中是否存在
  1. 根据判断结果执行后续操作
  • 如果关联对象已经存在,更新关联对象
  • 否则,插入关联对象
备注

Jimmer会尽量利用数据库本身的UPSERT的能力,将两个步骤合并。如果做不到,告诉用户原因

REPLACE后置关联MERGE的基础上,对不再需要的关联对象进行脱钩操作
VIOLENTLY_REPLACE后置关联

让Jimmer不再根据id或key去查找关联 (或关联集合) 的变化部分,而执行如下两个操作

  1. 先删除当前对象的所有旧关联和相关对象
  2. 再重新插入所有关联对象,并重建和当前对象的关联

这是一个非常暴力的关联更新手段,优缺点如下

  • 优点:接受wild关联对象,不再要求关联对象要么指定id要么指定key
  • 缺点:
    • 如果关联对象数量多,大部分情况下,这是一种低性能的方案
    • 如果关联对象还有更深的关联,第1步中的删除行为可能会导致过深的级联删除,导致过多数据的丢失

指定了关联模式的方法

可以为保存指令设置关联关系的AssociatedSaveMode,支持两种方法

  • 设置保存方法的参数 (这是更简单的选择)

  • 调用保存指令的配置方法 (这是更强大的选择)

信息

Jimmer的API设计让用户只能选者其中一种方法,不会导致导致两种方法同时出现引起混淆

设置保存方法的参数

很多保存方法都支持,例如

对于savesaveEntitiessaveInputs而言,默认的AssociatedSaveModeREPLACE,现在,我们将其修改为MERGE

  • save

    Book book = ......;
    sqlClient.save(book, AssociatedSaveMode.MERGE);
  • saveEntities

    List<Book> books = ......;
    sqlClient.saveEntities(books, AssociatedSaveMode.MERGE);
  • saveInputs

    List<BookInput> inputs = ......;
    sqlClient.saveInputs(inputs, AssociatedSaveMode.MERGE);

    其中,BookInput类并非实体类型,而是Jimmer自动生成的Input DTO类型。

    相关内容之前并未介绍,请读者先行忽略。

除了savesaveEntitiessaveInputs外,Jimmer还是有其他快捷方法,例如

  • insertinsertEntitiesinsertInputs
  • insertIfAbsentmergeEntitiesIfAbsentmergeInputsIfAbsent
  • updateupdateEntitiesupdateInputs
  • mergemergeEntitiesmergeInputs

以为insertinsertEntitiesinsertInputs为例,,默认的AssociatedSaveModeAPPEND,现在,我们将其修改为MERGE

  • insert

    Book book = ......;
    sqlClient.insert(book, AssociatedSaveMode.MERGE);
  • insertEntities

    List<Book> books = ......;
    sqlClient.insertEntities(books, AssociatedSaveMode.MERGE);
  • insertInputs

    List<BookInput> inputs = ......;
    sqlClient.insertInputs(inputs, AssociatedSaveMode.MERGE);

调用保存指令的配置方法

除了通过设置保存方法的参数外,我们还可以调用保存指令的配置方法修改AssociatedSaveMode

  • 精确设置特定关联的AssociatedSaveMode

    Book book = ......;
    sqlClient
    .saveCommand(book)
    .setAssociatedMode(
    BookProps.AUTHORS,
    AssociatedSaveMode.MERGE
    )
    .execute();
    • Java

      调用的是saveCommand方法,而非save方法,表示先创建保存指令,但并不马上执行。

      通过setAssociatedMode完成配置后,最后调用execute方法真正执行。

    • Kotlin

      语法相对简洁,仍然调用能直接执行保存指令的save方法,只是附带一个lambda完成更多配置。

  • 盲目设置所有关联的AssociatedSaveMode

    Book book = ......;
    sqlClient
    .saveCommand(book)
    .setAssociatedModeAll(
    AssociatedSaveMode.MERGE
    )
    .execute();
    • Java

      调用的是saveCommand方法,而非save方法,表示先创建保存指令,但并不马上执行。

      通过setAssociatedModeAll完成配置后,最后调用execute方法真正执行。

    • Kotlin

      语法相对简洁,仍然调用能直接执行保存指令的save方法,只是附带一个lambda完成更多配置。

信息

显然,对于特定的关关系而言,精确配置的优先级比盲目配置的优先级高。

1. APPEND

无条件地对关联对象进行INSERT操作

List<BookStore> stores = Arrays.asList(
Immutables.createBookStore(draft -> {
draft.setName("MANNING");
draft.addIntoBooks(book -> {
book.setName("SQL in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("49.9"));
});
draft.addIntoBooks(book -> {
book.setName("LINQ in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("39.9"));
});
}),
Immutables.createBookStore(draft -> {
draft.setName("AMAZON");
draft.addIntoBooks(book -> {
book.setName("C++ Primer");
book.setEdition(5);
book.setPrice(new BigDecimal("44.02"));
});
draft.addIntoBooks(book -> {
book.setName("Programming RUST");
book.setEdition(1);
book.setPrice(new BigDecimal("71.99"));
});
})
);

sqlClient.saveEntities(
stores,
AssociatedSaveMode.APPEND
);

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

将会生成两条SQL

  1. 对两个根对象进行UPSERT (insert或update) 操作

    保存根对象并非本文的讨论重点,默认折叠
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • 假设MANNING存在,现有id为2
    • 假设AMAZON存在,插入后,数据库自动编号新分配的id为100
  2. 对4个关联对象进行INSERT操作

    假设上个SQL保存根对象后

    • MANING的id为2
    • AMAZON的id为100

    生成如下SQL

    insert into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) values(?, ?, ?, ?)
    /* batch-0: [SQL in Action, 1, 49.9, 2] */
    /* batch-1: [LINQ in Action, 1, 39.9, 2] */
    /* batch-2: [C++ Primer, 5, 44.02, 100] */
    /* batch-3: [Programming RUST, 1, 71.99, 100] */

2. APPEND_IF_ABSENT

  1. 先判断关联对象在数据库中是否存在
  • 如果关联对象的id属性被指定了,按照id判断关联对象在数据库中是否存在
  • 否则,按照key判断关联对象在数据库中是否存在
  1. 根据判断结果执行后续操作
  • 如果关联对象已经存在,忽略操作是么也不做
  • 否则,插入关联对象
备注

Jimmer会尽量利用数据库本身的UPSERT的能力,将两个步骤合并。如果做不到,告诉用户原因

List<BookStore> stores = Arrays.asList(
Immutables.createBookStore(draft -> {
draft.setName("MANNING");
draft.addIntoBooks(book -> {
book.setName("SQL in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("49.9"));
});
draft.addIntoBooks(book -> {
book.setName("LINQ in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("39.9"));
});
}),
Immutables.createBookStore(draft -> {
draft.setName("AMAZON");
draft.addIntoBooks(book -> {
book.setName("C++ Primer");
book.setEdition(5);
book.setPrice(new BigDecimal("44.02"));
});
draft.addIntoBooks(book -> {
book.setName("Programming RUST");
book.setEdition(1);
book.setPrice(new BigDecimal("71.99"));
});
})
);

sqlClient.saveEntities(
stores,
AssociatedSaveMode.APPEND_IF_ABSENT
);

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

将会生成两条SQL

  1. 对两个根对象进行UPSERT (insert或update) 操作

    保存根对象并非本文的讨论重点,默认折叠
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • 假设MANNING存在,现有id为2
    • 假设AMAZON存在,插入后,数据库自动编号新分配的id为100
  2. 对4个关联对象进行INSERT_IF_ABSENT操作

    假设

    • Book对象的key为Book.nameBook.edition,且Book实体被@KeyUniqueConstraint注解修饰 (对于MySQL而言,需要@KeyUniqueConstraint(noMoreUniqueConstraints = true))

    • 上个SQL保存根对象后

      • MANING的id为2
      • AMAZON的id为100

    生成如下SQL

    merge into BOOK tb_1_ 
    using(values(?, ?, ?, ?)) tb_2_(
    NAME, EDITION, PRICE, STORE_ID
    )
    on
    tb_1_.NAME = tb_2_.NAME
    and
    tb_1_.EDITION = tb_2_.EDITION
    when not matched then
    insert(
    NAME, EDITION, PRICE, STORE_ID
    ) values(
    tb_2_.NAME, tb_2_.EDITION, tb_2_.PRICE, tb_2_.STORE_ID
    )
    /* batch-0: [SQL in Action, 1, 49.9, 2] */
    /* batch-1: [LINQ in Action, 1, 39.9, 2] */
    /* batch-2: [C++ Primer, 5, 44.02, 100] */
    /* batch-3: [Programming RUST, 1, 71.99, 100] */

3. UPDATE

  • 如果关联对象的id属性被指定了,按照id更新关联对象
  • 否则,按照key更新关联对象
List<BookStore> stores = Arrays.asList(
Immutables.createBookStore(draft -> {
draft.setName("MANNING");
draft.addIntoBooks(book -> {
book.setName("SQL in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("49.9"));
});
draft.addIntoBooks(book -> {
book.setName("LINQ in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("39.9"));
});
}),
Immutables.createBookStore(draft -> {
draft.setName("AMAZON");
draft.addIntoBooks(book -> {
book.setName("C++ Primer");
book.setEdition(5);
book.setPrice(new BigDecimal("44.02"));
});
draft.addIntoBooks(book -> {
book.setName("Programming RUST");
book.setEdition(1);
book.setPrice(new BigDecimal("71.99"));
});
})
);

sqlClient.saveEntities(
stores,
AssociatedSaveMode.UPDATE
);

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

将会生成两条SQL

  1. 对两个根对象进行UPSERT (insert或update) 操作

    保存根对象并非本文的讨论重点,默认折叠
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • 假设MANNING存在,现有id为2
    • 假设AMAZON存在,插入后,数据库自动编号新分配的id为100
  2. 对4个关联对象进行INSERT操作

    假设上个SQL保存根对象后

    • MANING的id为2
    • AMAZON的id为100

    生成如下SQL

    update BOOK
    set
    PRICE = ?,
    STORE_ID = ?
    where
    NAME = ?
    and
    EDITION = ?
    /* batch-0: [49.9, 2, SQL in Action, 1] */
    /* batch-1: [39.9, 2, LINQ in Action, 1] */
    /* batch-2: [44.02, 100, C++ Primer, 5] */
    /* batch-3: [71.99, 100, Programming RUST, 1] */

4. MERGE

  1. 先判断关联对象在数据库中是否存在
  • 如果关联对象的id属性被指定了,按照id判断关联对象在数据库中是否存在
  • 否则,按照key判断关联对象在数据库中是否存在
  1. 根据判断结果执行后续操作
  • 如果关联对象已经存在,更新关联对象
  • 否则,插入关联对象
备注

Jimmer会尽量利用数据库本身的UPSERT的能力,将两个步骤合并。如果做不到,告诉用户原因

List<BookStore> stores = Arrays.asList(
Immutables.createBookStore(draft -> {
draft.setName("MANNING");
draft.addIntoBooks(book -> {
book.setName("SQL in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("49.9"));
});
draft.addIntoBooks(book -> {
book.setName("LINQ in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("39.9"));
});
}),
Immutables.createBookStore(draft -> {
draft.setName("AMAZON");
draft.addIntoBooks(book -> {
book.setName("C++ Primer");
book.setEdition(5);
book.setPrice(new BigDecimal("44.02"));
});
draft.addIntoBooks(book -> {
book.setName("Programming RUST");
book.setEdition(1);
book.setPrice(new BigDecimal("71.99"));
});
})
);

sqlClient.saveEntities(
stores,
AssociatedSaveMode.MERGE
);

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

将会生成两条SQL

  1. 对两个根对象进行UPSERT (insert或update) 操作

    保存根对象并非本文的讨论重点,默认折叠
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • 假设MANNING存在,现有id为2
    • 假设AMAZON存在,插入后,数据库自动编号新分配的id为100
  2. 对4个关联对象进行INSERT_IF_ABSENT操作

    假设

    • Book对象的key为Book.nameBook.edition,且Book实体被@KeyUniqueConstraint注解修饰 (对于MySQL而言,需要@KeyUniqueConstraint(noMoreUniqueConstraints = true))

    • 上个SQL保存根对象后

      • MANING的id为2
      • AMAZON的id为100

    生成如下SQL

    merge into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) key(NAME, EDITION) values(
    ?, ?, ?, ?
    )
    /* batch-0: [SQL in Action, 1, 49.9, 2] */
    /* batch-1: [LINQ in Action, 1, 39.9, 2] */
    /* batch-2: [C++ Primer, 5, 44.02, 100] */
    /* batch-3: [Programming RUST, 1, 71.99, 100] */

5. REPLACE

MERGE模式对关联对象INSERTUPDATE操作,所以,保存执行后,关联对象的数量要么不变,要么变多,但不可能变少。

REPLACE模式不再有次特性,因为REPLACEMERGE的基础上,对不再需要的关联对象进行脱钩操作。

脱钩操作会去除当前对象不再需要的关联,例如

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

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

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

信息

脱钩操作会在后续教程中详细解介绍,因此,本文给出一个简化到极致的例子

List<Book> books = Arrays.asList(
Immutables.createBook(book -> {
book.setId(1L);
book.addIntoAuthors(author -> author.setId(2L));
book.addIntoAuthors(author -> author.setId(3L));
}),
Immutables.createBook(book -> {
book.setId(2L);
book.addIntoAuthors(author -> author.setId(2L));
book.addIntoAuthors(author -> author.setId(4L));
})
);
sqlClient.saveEntities(
books,
// 可以删除此参数,因为对`saveEntities`方法而言,
// `AssociatedSaveMode.REPLACE`是默认行为
AssociatedSaveMode.REPLACE
);

假设数据库中现有数据结构为下表左列

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



--+-Book(2)
|
+---Author(1)
|
\---Author(2)


--+-Book(1)
|
|
|
+---Author(2)
|
\---Author(3)

--+-Book(2)
|
|
|
+---Author(2)
|
\---Author(4)

显然,应该包含4个行为

  • REPLACE特有的脱钩操作

    • 断开Book-1Author-1之间的关联

    • 断开Book-2Author-1之间的关联

  • REPLACEMERGE那里继承的能力

    • 新建Book-1Author-3之间的关联

    • 新建Book-2Author-4之间的关联

最终会生成如下两条语句

  1. REPLACE模式特有的脱钩操作,消除旧关联

    delete from BOOK_AUTHOR_MAPPING
    where
    BOOK_ID = ?
    and
    not (AUTHOR_ID = any(?))
    /* batch-0: [1, [2, 3]] */
    /* batch-1: [2, [2, 4]] */

    此SQL的目的在于

    • 对于Book-1而言,删除它和除了Author-2Author-3之外的其他Author关联

      对于目前这个案例而言,其实就是断开Book-1Author-1之间的关联

    • 对于Book-2而言,删除它和除了Author-2Author-4之外的其他Author关联

      对于目前这个案例而言,其实就是断开Book-2Author-1之间的关联

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

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

    • MySQL采用了单条复杂SQL

    信息

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

  2. REPLACE模式从MERGE模式继承的能力,建立新关联

    merge into BOOK_AUTHOR_MAPPING tb_1_ 
    using(values(?, ?)) tb_2_(
    BOOK_ID, AUTHOR_ID
    )
    on
    tb_1_.BOOK_ID = tb_2_.BOOK_ID
    and
    tb_1_.AUTHOR_ID = tb_2_.AUTHOR_ID
    when not matched then
    insert(BOOK_ID, AUTHOR_ID)
    values(tb_2_.BOOK_ID, tb_2_.AUTHOR_ID)
    /* batch-0: [1, 2] */
    /* batch-1: [1, 3] */
    /* batch-2: [2, 2] */
    /* batch-3: [2, 3] */

REPLACE模式整合了INSERTUPDATEDELETE操作于一体,期望全量替换关联关系 (如果关联关系是深度很深的长关联,那就是全量替换一颗子树)

然而,REPLACE模式有一个限制,那就是它期望对象要么具备id,要么具备key

其实,REPALCE期望通过idkey巧妙地找到新旧数据结构发生变化的部分,从而把INSERTUPDATEDELETE操作的影响范围最小化,以达到最高性能。

提示

如果读者对web领域有所了解的话,不难发现,这和react使用循环渲染时指定标签的key属性是一个道理。

事实上,保持指令的设计就是受react启发而来。

6. VIOLENTLY_REPLACE

我们已经讨论了REPLACE模式。然而,如果要处理wild对象,即,关联对象既无id也无key,该怎么办呢?

此时可以使用VIOLENTLY_REPLACE模式。一旦采用此模式,让Jimmer不再根据id或key去查找关联 (或关联集合) 的变化部分,而执行如下两个操作

  1. 先删除当前对象的所有旧关联和相关对象
  2. 再重新插入所有关联对象并,并重建和当前对象的关联
List<BookStore> stores = Arrays.asList(
Immutables.createBookStore(draft -> {
draft.setName("MANNING");
draft.addIntoBooks(book -> {
book.setName("SQL in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("49.9"));
});
draft.addIntoBooks(book -> {
book.setName("LINQ in Action");
book.setEdition(1);
book.setPrice(new BigDecimal("39.9"));
});
}),
Immutables.createBookStore(draft -> {
draft.setName("AMAZON");
draft.addIntoBooks(book -> {
book.setName("C++ Primer");
book.setEdition(5);
book.setPrice(new BigDecimal("44.02"));
});
draft.addIntoBooks(book -> {
book.setName("Programming RUST");
book.setEdition(1);
book.setPrice(new BigDecimal("71.99"));
});
})
);

sqlClient.saveEntities(
stores,
AssociatedSaveMode.VIOLENTLY_REPLACE
);

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

将会生成三条SQL

  1. 对两个根对象进行UPSERT (insert或update) 操作

    保存根对象并非本文的讨论重点,默认折叠
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • 假设MANNING存在,现有id为2
    • 假设AMAZON存在,插入后,数据库自动编号新分配的id为100
  2. 删除所有就的关联对象

    假设上个SQL保存根对象后

    • MANING的id为2
    • AMAZON的id为100
    delete from BOOK 
    where STORE_ID = any(
    ? /* [2, 100] */
    )
    信息

    如果数据库有其他表通过外键引用BOOK表,简单的的DELETE语句并不能删除所有关联对象。

    为了简化讨论,这里假设这种场景并不存在。

  3. 新建所有关联,以及关联对象

    假设上个SQL保存根对象后

    • MANING的id为2
    • AMAZON的id为100
    insert into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) values(?, ?, ?, ?)
    /* batch-0: [SQL in Action, 1, 49.9, 2] */
    /* batch-1: [LINQ in Action, 1, 39.9, 2] */
    /* batch-2: [C++ Primer, 5, 44.02, 100] */
    /* batch-3: [Programming RUST, 1, 71.99, 100] */

不对新旧数据进行细致的比较,只是简单地先删除关联对象再重建,这是一个非常暴力的模式,存在如下缺点

  • 如果关联对象数量多,大部分情况下,这是一种低性能的方案
  • 如果关联对象还有更深的关联,第1步中的删除行为可能会导致过深的级联删除,导致过多数据的丢失
警告

因此,不推荐,请慎用

默认模式

保持指令提供的方法不仅有savesaveEntitiessaveInputs,为了方便各种应用场景,还其他了快捷方法。

不同的保存指令,AssociatedSaveMode的默认值并不相同,请参考此表

方法默认的AssociatedSaveMode
saveREPLACE
saveEntities
saveInputs
insertAPPEND
insertEntities
insertInputs
insertIfAbsentAPPEND_IF_ABSENT
insertEntitiesIfAbsent
insertInputsIfAbsent
updateUPDATE
updateEntities
updateInputs
mergeMERGE
mergeEntities
mergeInputs