How to map a composite identifier using an automatically @GeneratedValue with JPA and Hibernate

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!

Introduction

One of my readers asked me to answer the following StackOverflow question.

While I already covered the best way to map composite identifiers with JPA and Hibernate, this use case is different because one column is automatically generated.

Domain Model

Considering we have the following book database table:

Book table composite id generated

The identifier is formed out of two columns:

  • publisher_id which designates a given publisher
  • registration_number which is an auto-incremented number given by the Publisher

Now, we need to map this relation using JPA and Hibernate, so let’s see how we can do it.

Database supports SEQUENCE objects

If the database supports SEQUENCE objects natively, the mapping is really simple, and it’s probably the only use case where we ever need to use the JPA @IdClass.

So, we start with the @IdClass definition which will be used to wrap the composite identifier:

public class PK implements Serializable {

    private Long registrationNumber;

    private Integer publisherId;

    public PK(Long registrationNumber, Integer publisherId) {
        this.registrationNumber = registrationNumber;
        this.publisherId = publisherId;
    }

    private PK() {
    }

    //Getters and setters omitted for brevity

    @Override
    public boolean equals(Object o) {
        if ( this == o ) {
            return true;
        }
        if ( o == null || getClass() != o.getClass() ) {
            return false;
        }
        PK pk = (PK) o;
        return Objects.equals( registrationNumber, pk.registrationNumber ) &&
                Objects.equals( publisherId, pk.publisherId );
    }

    @Override
    public int hashCode() {
        return Objects.hash( registrationNumber, publisherId );
    }
}

The Book entity will look as follows:

@Entity(name = "Book")
@Table(name = "book")
@IdClass( PK.class )
public class Book {

    @Id
    @Column(name = "registration_number")
    @GeneratedValue
    private Long registrationNumber;

    @Id
    @Column(name = "publisher_id")
    private Integer publisherId;

    private String title;

    //Getters and setters omitted for brevity
}

Note that the registrationNumber uses the @GeneratedValue annotation since we want this column to be automatically generated on every insert.

Testing time

When running this test case:

Book _book = doInJPA(entityManager -> {
    Book book = new Book();
    book.setPublisherId( 1 );
    book.setTitle( "High-Performance Java Persistence");

    entityManager.persist(book);

    return book;
});

doInJPA(entityManager -> {
    PK key = new PK( _book.getRegistrationNumber(), 1);

    Book book = entityManager.find(Book.class, key);
    assertEquals( 
        "High-Performance Java Persistence", 
        book.getTitle() 
    );
});

Hibernate generates the following SQL statements:

SELECT NEXTVAL ('hibernate_sequence')

INSERT INTO book (title, publisher_id, registration_number) 
VALUES ('High-Performance Java Persistence', 1, 1)

SELECT 
    b.publisher_id as publishe1_0_0_, 
    b.registration_number as registra2_0_0_, 
    b.title as title3_0_0_ 
FROM 
    book b
WHERE 
    b.publisher_id = 1 AND 
    b.registration_number = 1

The @GeneratedValue annotation tells Hibernate to assign the registration_number column with a value coming from the associated database sequence.

Database supports IDENTITY objects

Now, things get a little bit more complicated if the database does not support SEQUENCE objects (e.g. MySQL 5.7).

This time, we can wrap the composite identifier in an Embeddable type:

@Embeddable
public class EmbeddedKey implements Serializable {

    @Column(name = "registration_number")
    private Long registrationNumber;

    @Column(name = "publisher_id")
    private Integer publisherId;

    //Getters and setters omitted for brevity

    @Override
    public boolean equals(Object o) {
        if ( this == o ) {
            return true;
        }
        if ( o == null || getClass() != o.getClass() ) {
            return false;
        }
        EmbeddedKey that = (EmbeddedKey) o;
        return Objects.equals( registrationNumber, that.registrationNumber ) &&
                Objects.equals( publisherId, that.publisherId );
    }

    @Override
    public int hashCode() {
        return Objects.hash( registrationNumber, publisherId );
    }
}

And the Book entity is going to be mapped as follows:

@Entity(name = "Book")
@Table(name = "book")
@SQLInsert( 
    sql = "insert into book (title, publisher_id, version) values (?, ?, ?)"
)
public class Book implements Serializable {

    @EmbeddedId
    private EmbeddedKey key;

    private String title;

    @Version
    @Column(insertable = false)
    private Integer version;

    //Getters and setters omitted for brevity
}

Because we can’t assign the @GeneratedValue on the Embeddable type, we rely on the database only to specify the IDENTITY column:

CREATE TABLE book (
    publisher_id INT NOT NULL, 
    registration_number BIGINT IDENTITY NOT NULL, 
    title VARCHAR(255), 
    version INT, 
    PRIMARY KEY (publisher_id, registration_number)
)

So, we just need to make sure we omit the registration_number column when inserting the post table row. Now, because the identifier columns are mandatory, we can’t just set its insertable attribute to false.

So, we need to provide a custom INSERT statement using the @SQLInsert Hibernate annotation. But because the NULL value from registrationNumber attribute will still be bound to the PreparedStatement, we can instruct Hibernate to set the version column instead.

Testing time

When running the previous test:

doInJPA(entityManager -> {
    Book book = new Book();
    book.setTitle( "High-Performance Java Persistence");

    EmbeddedKey key = new EmbeddedKey();
    key.setPublisherId(1);
    book.setKey(key);

    entityManager.persist(book);
});

doInJPA(entityManager -> {
    EmbeddedKey key = new EmbeddedKey();

    key.setPublisherId(1);
    key.setRegistrationNumber(1L);

    Book book = entityManager.find(Book.class, key);
    assertEquals( 
        "High-Performance Java Persistence", 
        book.getTitle() 
    );
});

Hibernate generates the following SQL statements:

INSERT INTO book (title, publisher_id, version) 
VALUES ('High-Performance Java Persistence', 1, NULL(BIGINT)

SELECT 
    b.publisher_id as publishe1_0_0_, 
    b.registration_number as registra2_0_0_, 
    b.title as title3_0_0_, 
    b.version as version4_0_0_ 
FROM book 
    b 
WHERE 
    b.publisher_id = 1 AND 
    b.registration_number = 1

That’s it!

I'm running an online workshop on the 11th of October about High-Performance SQL.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

Although not a very common mapping, you can map a composite identifier where one of the Primary Key columns is auto-generated. While for a SEQUENCE identifier, we can use the JPA specification, for IDENTITY, we need to use the Hibernate-specific @SQLInsert annotation. Nevertheless, this mapping is possible when using Hibernate.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.