21

So, I have this scenario where I need to take a header record, delete the details for it, then re-create the details in a different way. Updating the details would be way too much trouble.

I basically have:

@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);
}

Now, the database has a constraint like the following:

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

OK, when I run this and pass in 2, 3, 20, etc. customers, it creates all Detail records just fine as long as there weren't any before.

If I run it again, passing in a different list of customers, I expect ALL details to be deleted first then a list of NEW details to be created.

But what's happening is that the delete doesn't seem to be honored before the create. Because the error is a duplicate key constraint. The duplicate key is the "IN-VALID" scenario above.

If I manually populate the database with a bunch of details and comment out the CREATE details part (only run the delete) then the records are deleted just fine. So the delete works. The create works. It's just that both don't work together.

I can provide more code is needed. I'm using Spring Data JPA.

Thanks

UPDATE

My entities are annotated with basically the following:

@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;
...
}

UPDATE 2

@Klaus Groenbaek

Actually, I didn't mention this originally but I did it that way the first time. Also, I am using Cascading.ALL which I assume includes PERSIST.

Just for testing, I have updated my code to the following:

@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)
    }
}

Again...I want to reiterate....that the delete WILL WORK if I don't have the create immediately afterwards. The create WILL WORK if I don't have the delete immediately before it. But neither will work if they are together because of the duplicate key constraint error from the database.

I've tried the same scenario WITH and WITHOUT cascading deletes.

cbmeeks
  • 11,248
  • 22
  • 85
  • 136

4 Answers4

33

Hold on to your hat, as this is a rather long explanation, but when I look at your code, it looks like you are missing a couple of key concepts about how JPA works.

First, adding Entities to a collection or removing entities from a collection does not mean that that the same operation will occur in the database, unless a persistence operation is propagated using cascadeding or orphanRemoval.

For an entity to be added to the database, you must call EntityManager.persist() either directly, or through cascading persist. This is basically what happens inside JPARepository.save()

If you wish to remove an entity, you must call EntityManager.remove() directly or by cascading the operation, or through JpaRepository.delete().

If you have a managed entity (one that is loaded into a persistence context), and you modify a basic field (non-entity, non-collection) inside a transaction, then this change is written to the database when the transaction commits, even if you did not call persist/save. The persistence context keeps a internal copy of every loaded entity, and when a transaction commits it loops through the internal copies and compares to the current state, and any basic filed changes triggers an update query.

If you have added a new Entity (A) to a collection on another entity (B), but have not called persist on A then A will not be saved to the database. If you call persist on B one of two things will happen, if the persist operation is cascaded, A will also be saved to the database. If persist is not cascaded you will get an error, because a managed entity refers to an unmanaged entity, which give this error on EclipseLink: "During synchronization a new object was found through a relationship that was not marked cascade PERSIST". Cascade persist makes sense because you often create a parent entity and it's children at the same time.

When you want to remove an Entity A from a collection on another Entity B, you can't rely on cascading, since you are not removing B. Instead you have to call remove on A directly, removing it from the collection on B does not have any effect, as no persistence operation has been called on the EntityManager. You can also use orphanRemoval to trigger delete, but I would advise you to be careful when using this feature, especially since you seem to be missing some basic knowledge about how persistence operations work.

Normally it helps to think about the persistence operation, and which entity it must be applied to. Here is how the code would have looked if I had written it.

@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);
    }
}

First there is no reason to persist the Header, it is a managed entity and any basic field you modify will be change when the transaction commits. Header happens to be the foreign key for the Details entity, which means the important thing is detail.setHeader(header); and em.persist(details), since you must set all foreign relations, and persist any new Details. Likewise, removing existing details from a Header, has nothing to do with the Header, the defining relation (foreign key) is in Details, so removing details from the persistence context is what removes it from the database. You can also use orphanRemoval, but this require additional logic for each transaction, and In my opinion the code is easier to read if each peristence operation is explicit, that way you don't need to go back to the entity to read the annotations.

Finally: The sequence of persistence operation in your code, does not transalte to the order of queries executed against the database. Both Hibernate and EclipseLink will insert new entities first, and then delete existing entities. In my experience this is the most common reason for "Primary key already exist". If you remove an entity with a specific primary key, and then add a new entity with the same primary key, then the insert will occur first, and cause a key violation. This can be fixed by telling JPA to flush the current Persistence state to the database. em.flush() will push the delete queries to the database, so you can insert another row with the same primary key as one you have deleted.

That was a lot of information, please let me know if there was anything you did not understand, or need me to clarify.

Klaus Groenbaek
  • 4,820
  • 2
  • 15
  • 30
  • 9
    *"Both Hibernate and EclipseLink will insert new entities first, and then delete existing entities"* was key for me, flushing between a `DELETE` and `INSERT` helped (y). – luk2302 Mar 21 '19 at 13:05
  • Good explanation but without a details. Official explanation is mentioned only in docs for [AbstractFlushingEventListener](https://docs.jboss.org/hibernate/orm/3.5/javadocs/org/hibernate/event/def/AbstractFlushingEventListener.html#performExecutions%28org.hibernate.event.EventSource%29) Found more info in this [post] (https://stackoverflow.com/questions/12616336/how-is-hibernate-deciding-order-of-update-insert-delete) And deeper discussion [here](https://discourse.hibernate.org/t/about-the-order-of-sql-execution-when-flushing-to-the-database/1425) – amisiuryk Apr 11 '22 at 18:23
  • And instead of manual flushing _UPDATE_ can be used. Since delete and then insert it means to update but map all fields properly. – amisiuryk Apr 11 '22 at 18:26
  • @luk2302 Does flush lets the transaction to be done properly? Aren't any drawbacks regarding flush? – giannis christofakis May 22 '22 at 14:38
  • 1
    Flush determines when the database sees the im-memory changes in the persistence context. The cost is the JPA has to calculate the updates for any managed entity which is out-of-sync with the DB, so comparing all modified entities to the state they had when loaded or last flushed. Normally you only need to care about automatic flushing when you are trying to optimize batch insert/update, the trick is not to interleave queries and modifications. Flushing is not related to DB transactions in any way. – Klaus Groenbaek Jun 23 '22 at 07:35
15

The cause is described by @klaus-groenbaek but I noticed something funny while working around it.

While using the Spring JpaRepository I was not able to get this to work when using a derived method.

So the following does NOT work:

void deleteByChannelId(Long channelId);

But specifying an explicit (Modifying) Query makes it work correctly, so the following works:

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

In this case the statements are committed / persisted in the correct order.

  • 5
    Unfortunately this is the only way it works. Should run out-of-the-box for CrudRepository. We do not re-create having same primary key, but have a unique constraint on some columns. Inserting with same unique constraint values immediately after deleting an existing one need the same workaround. @Martin: Thanks a lot for the workaround. – Mick Belker - Pseudonym May 13 '20 at 10:40
  • thanks to @martin... I was also struggling and this solution worked for me. But what's the reason, why it works in this manner correctly. – oOXAam Dec 21 '20 at 06:35
  • 1
    Ok people the catch here is, @Query is important if you do not write it, then it will not work with the only "MODIFYING". This is surely a bug. I have also seen a thread in GitHub on this – Aadam Aug 10 '21 at 21:00
  • 1
    When you use the deleteByX methods in Spring's JpaRepository, the persistence context is kept in-sync with the database. This means that JPA will first load the entity into memory and the call EntityManager::remove, which will also trigger any cascade on delete operations defined by JPA. When you use an explicit @Query, the behavior is different, as this is purely a database operation, and per the JPA spec, the in.-memory persistence context is not updated. – Klaus Groenbaek Jun 23 '22 at 07:40
1

First of all, just performing header.getDetails().remove(detail); do not perform any kind of operations on DB. I suppose that in headerService.save(header); you invoke something like session.saveOrUpdate(header).

Basically it is some kind of logical collision, because Hibernate needs to delete and create entities with duplicate keys in one operation, but it doesn't know order in which these operations should be performed.

I would suggest at least invoke headerService.save(header); before adding new details, i.e. like this:

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

in order to tell to Hibernate: yes, delete this entities, which I have removed from collection, and after that add new entites.

Andremoniy
  • 34,031
  • 20
  • 135
  • 241
0

Probably em.flush() do the trick.

so the code could be:

@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);
    }
    detailRepository.flush();


    // 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)
    }
}
`
hitesh
  • 176
  • 1
  • 3