Hibernate not maintaining order of List of @OneToMany with @OrderColumn when list updated

I am hesitant to suggest this is a bug - because it seems like such bread-and-butter functionality that is broken, which surely is unlikely.

(I am using Hibernate 5.4.0.Final - and also tested with 5.4.2.Final)

I have a simple uni-directional Parent-Child object relationship where a parent has one-to-many children.

I can save the object graph no problem.

If I then start with a detached Parent and :

  1. Re-order the list (say Collections.shuffle(children) ) and merge the Parent, no SQL updates are executed & the changes to the List order are not persisted.
  2. Remove an item from the middle of the list (say children.remove(1) ) and merge the Parent, an SQL delete is executed as expected, however no updates are executed to update the OrderColumn
  3. If I remove an item from the middle of the list AND add an item at the end of the list, hilarity ensues. A delete is executed as expected, an insert is executed as expected, but now two child rows in the database will have the same OrderColumn value

Is this a bug? Or have I forgotten how hibernate works? (Admittedly, its been a few years since I have used this aspect of hibernate)

Here are my classes:
Parent

@Entity
public class Parent {

    @Id
    @Column
    private UUID id;

    @Column
    private String name;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    @JoinColumn(name = "parent_id", nullable = false, updatable = false)
    @OrderColumn(name = "child_index", nullable = false)
    private List<Child> children = new ArrayList<>();

    ...

    public Parent removeChild() {
        children.remove(1);
        return this;
    }

    public Parent addChild() {
        children.add(new Child("new"));
        return this;
    }

    public Parent reorderChildren() {
        Collections.shuffle(children);
        return this;
    }
}

Child

@Entity
public class Child {

    @Id
    @Column
    private UUID id;

    @Column
    private String name;

    ...
}

Here is an extract from the ORMUnitTestCase test-case-template

Save a Parent

    private final Parent parent = new Parent("barry", new ArrayList<>(Arrays.asList(new Child("one"), new Child("two"), new Child("three"), new Child("four"))));

    @Before
    public void setUp() throws SQLException {
        doInNewSessionAndTx(session -> {
            System.out.println("******** SAVE session.save()");
            Parent detached = detached(parent);
            session.save(detached);
        });
        printActualTable("Child table after save...");
    }

FYI detached() is just doing a manual clone of the parent object - simulating, say, a JSON serialisation/deserialisation round-trip

Re-ordering children

    @Test
    public void reOrderingChildrenIsNotPersisted() throws SQLException {
        Parent expectedUpdate = detached(parent)
                .reorderChildren();

        doInNewSessionAndTx(session -> {
            System.out.println("******** MERGE session.merge()");
            session.merge(expectedUpdate);
        });
        Parent loaded = loadById(parent.getId());
        printActualTable("Child table after merge :-o");

        assertEquals(expectedUpdate, loaded);
    }

Remove child and add new one

    @Test
    public void removingChildrenDoesNotUpdateProperly2() throws SQLException {
        Parent expectedUpdate = detached(parent)
                .removeChild()
                .addChild();

        doInNewSessionAndTx(session -> {
            System.out.println("******** MERGE session.merge()");
            session.merge(expectedUpdate);
        });
        Parent loaded = loadById(parent.getId());
        printActualTable("Child table after merge :-o");

        assertEquals(expectedUpdate, loaded);
    }

Here is the std-out of first test:

******** SAVE session.save()
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: insert into Parent (name, id) values (?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
=============================================================================
Child table after save...
=============================================================================
ID                               PARENT_ID                        IDX NAME
69e8d7e2a3404ae593b43892b9ce864c bf27d244622e40379b558cca3e712a9e 0   one
67a24f9312684a5a9c019925c0537b0d bf27d244622e40379b558cca3e712a9e 1   two
89653117d9094bd1941be7e4bbacd764 bf27d244622e40379b558cca3e712a9e 2   three
7601535cb1fe4151bbee578dc047ffdc bf27d244622e40379b558cca3e712a9e 3   four
=============================================================================
******** MERGE session.merge()
Hibernate: select parent0_.id as id1_1_1_, parent0_.name as name2_1_1_, children1_.parent_id as parent_i3_0_3_, children1_.id as id1_0_3_, children1_.child_index as child_in4_3_, children1_.id as id1_0_0_, children1_.name as name2_0_0_ from Parent parent0_ left outer join Child children1_ on parent0_.id=children1_.parent_id where parent0_.id=?
******** GET BY ID
Hibernate: select parent0_.id as id1_1_0_, parent0_.name as name2_1_0_, children1_.parent_id as parent_i3_0_1_, children1_.id as id1_0_1_, children1_.child_index as child_in4_1_, children1_.id as id1_0_2_, children1_.name as name2_0_2_ from Parent parent0_ left outer join Child children1_ on parent0_.id=children1_.parent_id where parent0_.id=?
=============================================================================
Child table after merge
=============================================================================
ID                               PARENT_ID                        IDX NAME
69e8d7e2a3404ae593b43892b9ce864c bf27d244622e40379b558cca3e712a9e 0   one
67a24f9312684a5a9c019925c0537b0d bf27d244622e40379b558cca3e712a9e 1   two
89653117d9094bd1941be7e4bbacd764 bf27d244622e40379b558cca3e712a9e 2   three
7601535cb1fe4151bbee578dc047ffdc bf27d244622e40379b558cca3e712a9e 3   four
=============================================================================

Here is the std-out of the 2nd test

******** SAVE session.save()
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: insert into Parent (name, id) values (?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
=============================================================================
Child table after save...
=============================================================================
ID                               PARENT_ID                        IDX NAME
1dac1cecc17347f59dd527697b18a096 4c5c06ef232f4d7cb471eaa05c50cb5a 0   one
94f38a6a8b634f3c84703c5669ada229 4c5c06ef232f4d7cb471eaa05c50cb5a 1   two
fdfad4f8e7574b64a6f8352200a462e6 4c5c06ef232f4d7cb471eaa05c50cb5a 2   three
6103df0cfc4e484ba6936dcf9eb28e44 4c5c06ef232f4d7cb471eaa05c50cb5a 3   four
=============================================================================
******** MERGE session.merge()
Hibernate: select parent0_.id as id1_1_1_, parent0_.name as name2_1_1_, children1_.parent_id as parent_i3_0_3_, children1_.id as id1_0_3_, children1_.child_index as child_in4_3_, children1_.id as id1_0_0_, children1_.name as name2_0_0_ from Parent parent0_ left outer join Child children1_ on parent0_.id=children1_.parent_id where parent0_.id=?
Hibernate: select child0_.id as id1_0_0_, child0_.name as name2_0_0_ from Child child0_ where child0_.id=?
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: delete from Child where id=?
******** GET BY ID
Hibernate: select parent0_.id as id1_1_0_, parent0_.name as name2_1_0_, children1_.parent_id as parent_i3_0_1_, children1_.id as id1_0_1_, children1_.child_index as child_in4_1_, children1_.id as id1_0_2_, children1_.name as name2_0_2_ from Parent parent0_ left outer join Child children1_ on parent0_.id=children1_.parent_id where parent0_.id=?
=============================================================================
Child table after merge :-o
=============================================================================
ID                               PARENT_ID                        IDX NAME
1dac1cecc17347f59dd527697b18a096 4c5c06ef232f4d7cb471eaa05c50cb5a 0   one
fdfad4f8e7574b64a6f8352200a462e6 4c5c06ef232f4d7cb471eaa05c50cb5a 2   three
6103df0cfc4e484ba6936dcf9eb28e44 4c5c06ef232f4d7cb471eaa05c50cb5a 3   four
4d754d0f13f848a28e994babdf77e8cb 4c5c06ef232f4d7cb471eaa05c50cb5a 3   new
=============================================================================

It would appear this is also an issue with non-detached entities. Wat?

Same entities, different test

    @Test
    public void removingChildrenAndAddingDoesInTxNotUpdateProperly() throws SQLException {
        Parent[] expectedUpdate = new Parent[1];
        doInNewSessionAndTx(session -> {
            System.out.println("******** LOAD & UPDATE");
            Parent toUpdate = session.get(Parent.class, parent.getId());
            toUpdate.removeChild()
                    .addChild();
            expectedUpdate[0] = toUpdate;
        });
        Parent loaded = loadById(parent.getId());
        printActualTable("Child table after load & update");

        assertEquals(expectedUpdate[0], loaded);
    }

Lets look at the output.

******** SAVE session.save()
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: insert into Parent (name, id) values (?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
=============================================================================
Child table after save...
=============================================================================
ID                               PARENT_ID                        IDX NAME
2ffb47b8eb254cc09d438676164598ee a9a076137d7d44fb84a919ee056d1717 0   one
999f8759ec454b3e9c4f9e093fbada91 a9a076137d7d44fb84a919ee056d1717 1   two
a73bf33d4c8f42f9a2feedf2f74f3741 a9a076137d7d44fb84a919ee056d1717 2   three
e6d92410e2e24d01b02ea7c9b9a44b0d a9a076137d7d44fb84a919ee056d1717 3   four
=============================================================================
******** LOAD & UPDATE
Hibernate: select parent0_.id as id1_1_0_, parent0_.name as name2_1_0_, children1_.parent_id as parent_i3_0_1_, children1_.id as id1_0_1_, children1_.child_index as child_in4_1_, children1_.id as id1_0_2_, children1_.name as name2_0_2_ from Parent parent0_ left outer join Child children1_ on parent0_.id=children1_.parent_id where parent0_.id=?
Hibernate: select child_.id, child_.name as name2_0_ from Child child_ where child_.id=?
Hibernate: insert into Child (name, parent_id, child_index, id) values (?, ?, ?, ?)
Hibernate: delete from Child where id=?
******** GET BY ID
Hibernate: select parent0_.id as id1_1_0_, parent0_.name as name2_1_0_, children1_.parent_id as parent_i3_0_1_, children1_.id as id1_0_1_, children1_.child_index as child_in4_1_, children1_.id as id1_0_2_, children1_.name as name2_0_2_ from Parent parent0_ left outer join Child children1_ on parent0_.id=children1_.parent_id where parent0_.id=?
=============================================================================
Child table after load & update
=============================================================================
ID                               PARENT_ID                        IDX NAME
2ffb47b8eb254cc09d438676164598ee a9a076137d7d44fb84a919ee056d1717 0   one
a73bf33d4c8f42f9a2feedf2f74f3741 a9a076137d7d44fb84a919ee056d1717 2   three
e6d92410e2e24d01b02ea7c9b9a44b0d a9a076137d7d44fb84a919ee056d1717 3   four
c0cd6a405815452da4466577fa44e536 a9a076137d7d44fb84a919ee056d1717 3   new
=============================================================================

I must be doing something wrong…

Hm, perhaps I am doing something wrong, or perhaps its a bug.

It appears that removing the updatable = false on the @JoinColumn fixes it.

That doesn’t make sense to me.

Here is the fixed class/mapping

@Entity
public class Parent {

    @Id
    @Column
    private UUID id;

    @Column
    private String name;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    @JoinColumn(name = "parent_id", nullable = false/*, updatable = false*/)
    @OrderColumn(name = "child_index", nullable = false)
    private List<Child> children = new ArrayList<>();
}

https://hibernate.atlassian.net/browse/HHH-13399

I am kinda surprised. The hibernate community isnt as lively as it used to be.

Three weeks and zero response/feedback on the jira bug report (https://hibernate.atlassian.net/browse/HHH-13399) or on this topic…