删除然后创建记录导致与 Spring Data JPA 的重复键冲突

Delete then create records are causing a duplicate key violation with Spring Data JPA

所以,我有这样的场景,我需要获取 header 记录,删除它的详细信息,然后 re-create 以不同的方式删除详细信息。更新详情太麻烦了

我基本上有:

@Transactional
public void create(Integer id, List<Integer> customerIDs) {

    Header header = headerService.findOne(id);
    // header is found, has multiple details

    // Remove the details
    for(Detail detail : header.getDetails()) {
        header.getDetails().remove(detail);
    }

    // Iterate through list of ID's and create Detail with other objects
    for(Integer id : customerIDs) {
        Customer customer = customerService.findOne(id);

        Detail detail = new Detail();
        detail.setCustomer(customer);

        header.getDetails().add(detail);
    }

    headerService.save(header);
}

现在,数据库有如下约束:

Header
=================================
ID, other columns...

Detail
=================================
ID, HEADER_ID, CUSTOMER_ID

Customer
=================================
ID, other columns...

Constraint:  Details must be unique by HEADER_ID and CUSTOMER_ID so:

Detail  (VALID)
=================================
1, 123, 10
2, 123, 12

Detail  (IN-VALID)
=================================
1, 123, 10
1, 123, 10

好的,当我 运行 这个并传入 2、3、20 等客户时,它会创建所有 Detail 记录,只要之前没有任何记录。

如果我再次 运行 传递不同的客户列表,我希望首先删除 ALL 详细信息,然后创建 NEW 详细信息列表。

但实际情况是删除似乎在创建之前没有得到遵守。因为错误是一个重复的键约束。重复键就是上面的"IN-VALID"场景

如果我用一堆详细信息手动填充数据库并注释掉 CREATE details 部分(仅 运行 删除),那么记录就被删除了。所以删除有效。创建作品。只是两者不能一起工作。

我可以提供更多需要的代码。我正在使用 Spring Data JPA.

谢谢

更新

我的实体基本上用以下注释:

@Entity
@Table
public class Header {
...
    @OneToMany(mappedBy = "header", orphanRemoval = true, cascade = {CascadeType.ALL}, fetch = FetchType.EAGER)
    private Set<Detail> Details = new HashSet<>();

...
}

@Entity
@Table
public class Detail {
...
    @ManyToOne(optional = false)
    @JoinColumn(name = "HEADER_ID", referencedColumnName = "ID", nullable = false)
    private Header header;
...
}

更新 2

@Klaus Groenbaek

其实我一开始并没有提到这个,但我第一次就这么说了。此外,我正在使用 Cascading.ALL,我认为它包括 PERSIST。

为了测试,我已将代码更新为以下内容:

@Transactional
public void create(Integer id, List<Integer> customerIDs) {

    Header header = headerService.findOne(id);

    // Remove the details
    detailRepository.delete(header.getDetails());       // Does not work

    // I've also tried this:
    for(Detail detail : header.getDetails()) {
        detailRepository.delete(detail);
    }


    // Iterate through list of ID's and create Detail with other objects
    for(Integer id : customerIDs) {
        Customer customer = customerService.findOne(id);

        Detail detail = new Detail();
        detail.setCustomer(customer);
        detail.setHeader(header);

        detailRepository.save(detail)
    }
}

再次......我想重申......如果我没有立即创建,删除将起作用。如果我没有紧接在它之前的删除,则创建将起作用。但是由于数据库中的重复键约束错误,如果它们在一起,它们都不会起作用。

我已经尝试过使用和不使用级联删除的相同场景。

首先,只是执行 header.getDetails().remove(detail); 不要对数据库执行任何类型的操作。我想在 headerService.save(header); 中你会调用类似 session.saveOrUpdate(header).

的东西

基本上这是某种逻辑冲突,因为 Hibernate 需要在一次操作中删除和创建具有重复键的实体,但是它不知道这些操作的顺序应该执行。

我建议至少调用 headerService.save(header); before 添加新的细节,即像这样:

    // Remove the details
    for(Detail detail : header.getDetails()) {
        header.getDetails().remove(detail);
    }

    headerService.save(header);

    // Iterate through list of ID's and create Detail with other objects
    for(Integer id : customerIDs) {
        // ....
    }

    headerService.save(header);

为了告诉 Hibernate:是的,删除我已从集合中删除的这个实体,然后添加新实体。

坚持你的帽子,因为这是一个相当长的解释,但当我查看你的代码时,你似乎遗漏了几个关于 JPA 工作原理的关键概念。

首先,将实体添加到 collection 或从 collection 中删除实体并不意味着相同的操作将发生在数据库中,除非使用级联或 orphanRemoval 传播持久性操作.

要将实体添加到数据库,您必须直接调用 EntityManager.persist() 或通过级联持久化。这基本上就是 JPARepository.save()

里面发生的事情

如果您希望删除一个实体,您必须直接调用 EntityManager.remove() 或通过级联操作,或通过 JpaRepository.delete().

如果您有一个托管实体(加载到持久性上下文中的实体),并且您修改了事务中的基本字段(non-entity、non-collection),那么此更改将写入在事务提交时发送到数据库,即使您没有调用 persist/save。持久性上下文保留每个加载实体的内部副本,当事务提交时,它循环遍历内部副本并与当前状态进行比较,任何基本的归档更改都会触发更新查询。

如果您已将新实体 (A) 添加到另一个实体 (B) 上的 collection,但尚未对 A 调用持久化,则 A 将不会保存到数据库中。如果你在 B 上调用 persist 会发生两种情况之一,如果 persist 操作是级联的,A 也会被保存到数据库中。如果 persist 没有级联,你会得到一个错误,因为一个托管实体引用一个非托管实体,它在 EclipseLink 上给出这个错误:"During synchronization a new object was found through a relationship that was not marked cascade PERSIST"。 Cascade persist 是有道理的,因为您经常创建一个 parent 实体并且它同时是 children。

当您想从另一个实体 B 上的 collection 中删除实体 A 时,您不能依赖级联,因为您没有删除 B。相反,您必须直接对 A 调用删除,从 B 上的 collection 中删除它没有任何效果,因为没有在 EntityManager 上调用持久性操作。您也可以使用 orphanRemoval 来触发删除,但我建议您在使用此功能时要小心,尤其是因为您似乎缺少有关持久性操作如何工作的一些基本知识。

通常考虑持久性操作以及它必须应用于哪个实体会有所帮助。如果我编写代码,代码会是这样的。

@Transactional
public void create(Integer id, List<Integer> customerIDs) {

    Header header = headerService.findOne(id);
    // header is found, has multiple details

    // Remove the details
    for(Detail detail : header.getDetails()) {
        em.remove(detail);
    }

    // em.flush(); // In some case you need to flush, see comments below

    // Iterate through list of ID's and create Detail with other objects
    for(Integer id : customerIDs) {
        Customer customer = customerService.findOne(id);

        Detail detail = new Detail();
        detail.setCustomer(customer);
        detail.setHeader(header);  // did this happen inside you service?
        em.persist(detail);
    }
}

首先,没有理由持久化Header,它是一个托管实体,您修改的任何基本字段都将在事务提交时更改。 Header 恰好是 Details 实体的外键,这意味着重要的是 detail.setHeader(header);em.persist(details),因为您必须设置所有外关系,并保留任何新的 Details. 同样,从 Header 中删除现有详细信息与 Header 无关,定义关系(外键)在详细信息中,因此从持久性上下文中删除详细信息就是将其从数据库中删除.您也可以使用 orphanRemoval,但这需要为每个事务添加额外的逻辑,并且在我看来,如果每个持久化操作都是显式的,那么代码更容易阅读,这样您就不需要返回到实体来阅读注释。

最后:您代码中的持久化操作顺序不会转换为对数据库执行查询的顺序。 Hibernate 和 EclipseLink 都会先插入新实体,然后删除现有实体。根据我的经验,这是 "Primary key already exist" 最常见的原因。如果您删除具有特定主键的实体,然后添加具有相同主键的新实体,则插入将首先发生,并导致键冲突。这可以通过告诉 JPA 将当前持久性状态刷新到数据库来解决。 em.flush() 会将删除查询推送到数据库,因此您可以插入与已删除的主键相同的另一行。

信息量很大,如果有什么不明白的,或者需要我解释的,请告诉我。

@klaus-groenbaek 描述了原因,但我在处理它时注意到一些有趣的事情。

在使用 Spring JpaRepository 时,我无法在使用派生方法时使用它。

因此以下内容不起作用:

void deleteByChannelId(Long channelId);

但是指定显式 (Modifying) Query 可以使其正常工作,因此以下工作:

@Modifying
@Query("delete from ClientConfigValue v where v.channelId = :channelId")
void deleteByChannelId(@Param("channelId") Long channelId);

在这种情况下,语句以正确的顺序提交/持久化。