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:
The identifier is formed out of two columns:
publisher_id
which designates a given publisherregistration_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.
