How to map the Java YearMonth type 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
In this article, we are going to see how you can map a java.time.YearMonth
with both JPA and Hibernate.
As I explained in this article, JPA 2.2 supports the following Date/Time types introduced in Java 8:
java.time.LocalDate
java.time.LocalTime
java.time.LocalDateTime
java.time.OffsetTime
java.time.OffsetDateTime
Apart from supporting those, Hibernate supports also:
java.time.Duration
java.time.Instant
java.time.ZonedDateTime
However, neither JPA nor Hibernate support the java.time.YearMonth
out-of-the-box. As you will see, adding support for java.time.YearMonth
is really straightforward for both standard JPA or Hibernate.
How you can map a java.time.YearMonth with both JPA and #Hibernate. @vlad_mihalceahttps://t.co/vARaRx888n pic.twitter.com/JwO3vyEFqU
— Java (@java) October 21, 2018
Domain Model
Let’s consider we have the following Book
entity:
Mapping this entity to a database table requires choosing a column type for the YearMonth
property. For this purpose we have the following options:
- We could save it in a
String
column type (e.g.,CHAR(6)
), but that will require 6-byte storage. - We could save it as a
Date
column type, which requires 4 bytes. - We could save it in a 3 or 4-byte
Integer
column type.
Because the String
alternative is the least efficient, we are going to choose the Date
and the Integer
alternatives instead.
Persisting YearMonth as a Date using JPA
When using JPA, we can use the AttributeConverter
interface to define the conversion logic between the Java YearMonth
type and the java.sql.Date
one.
public class YearMonthDateAttributeConverter implements AttributeConverter<YearMonth, java.sql.Date> { @Override public java.sql.Date convertToDatabaseColumn( YearMonth attribute) { if (attribute != null) { return java.sql.Date.valueOf( attribute.atDay(1) ); } return null; } @Override public YearMonth convertToEntityAttribute( java.sql.Date dbData) { if (dbData != null) { return YearMonth.from( Instant .ofEpochMilli(dbData.getTime()) .atZone(ZoneId.systemDefault()) .toLocalDate() ); } return null; } }
Now, we can map the entity as follows:
@Entity(name = "Book") @Table(name = "book") public class Book { @Id @GeneratedValue private Long id; @NaturalId private String isbn; private String title; @Column( name = "published_on", columnDefinition = "date" ) @Convert( converter = YearMonthDateAttributeConverter.class ) private YearMonth publishedOn; //Getters and setters omitted for brevity }
When persisting a the following Book
entity on PostgreSQL:
Book book = new Book(); book.setIsbn("978-9730228236"); book.setTitle("High-Performance Java Persistence"); book.setPublishedOn(YearMonth.of(2016, 10)); entityManager.persist(book);
Hibernate generates the following SQL INSERT statement:
INSERT INTO book ( isbn, published_on, title, id ) VALUES ( '978-9730228236', '2016-10-01', 'High-Performance Java Persistence', 1 )
Notice the ‘2016-10-01’ value used for the published_on
column.
And we can fetch the entity:
Book book = entityManager .unwrap(Session.class) .bySimpleNaturalId(Book.class) .load("978-9730228236"); assertEquals( YearMonth.of(2016, 10), book.getPublishedOn() );
And the publishedOn
property is going to be properly set by Hibernate.
We can also reference the publishedOn
property in an entity query, like this one:
Book book = entityManager.createQuery(""" select b from Book b where b.title = :title and b.publishedOn = :publishedOn """, Book.class) .setParameter("title", "High-Performance Java Persistence") .setParameter("publishedOn", YearMonth.of(2016, 10)) .getSingleResult();
Persisting YearMonth as a Date using Hibernate
You can achieve the same goal using a Hibernate custom type:
public class YearMonthDateType extends AbstractSingleColumnStandardBasicType<YearMonth> { public static final YearMonthDateType INSTANCE = new YearMonthDateType(); public YearMonthDateType() { super( DateTypeDescriptor.INSTANCE, YearMonthTypeDescriptor.INSTANCE ); } public String getName() { return "yearmonth-date"; } @Override protected boolean registerUnderJavaType() { return true; } }
While the DateTypeDescriptor
is a Hibernate SQL descriptor provided via the hibernate-core
dependency, the YearMonthTypeDescriptor
is a class that I wrote for myself and looks as follows:
public class YearMonthTypeDescriptor extends AbstractTypeDescriptor<YearMonth> { public static final YearMonthTypeDescriptor INSTANCE = new YearMonthTypeDescriptor(); public YearMonthTypeDescriptor() { super(YearMonth.class); } @Override public boolean areEqual( YearMonth one, YearMonth another) { return Objects.equals(one, another); } @Override public String toString( YearMonth value) { return value.toString(); } @Override public YearMonth fromString( String string) { return YearMonth.parse(string); } @SuppressWarnings({"unchecked"}) @Override public <X> X unwrap( YearMonth value, Class<X> type, WrapperOptions options) { if (value == null) { return null; } if (String.class.isAssignableFrom(type)) { return (X) toString(value); } if (Number.class.isAssignableFrom(type)) { Integer numericValue = (value.getYear() * 100) + value.getMonth().getValue(); return (X) (numericValue); } if (Date.class.isAssignableFrom(type)) { return (X) java.sql.Date.valueOf(value.atDay(1)); } throw unknownUnwrap(type); } @Override public <X> YearMonth wrap( X value, WrapperOptions options) { if (value == null) { return null; } if (value instanceof String) { return fromString((String) value); } if (value instanceof Number) { int numericValue = ((Number) (value)).intValue(); int year = numericValue / 100; int month = numericValue % 100; return YearMonth.of(year, month); } if (value instanceof Date) { Date date = (Date) value; return YearMonth .from(Instant.ofEpochMilli(date.getTime()) .atZone(ZoneId.systemDefault()) .toLocalDate()); } throw unknownWrap(value.getClass()); } }
You don’t have to write these Hibernate type yourself since it’s already available via the Hypersistence Utils project, which offers support for JSON, ARRAY, DB-specific Enums, PostgreSQL INET type and more.
With the YearMonthDateType
in place, we only need to add the type to the entity mapping.
For Hibernate 6, the mapping will look as follows:
@Entity(name = "Book") @Table(name = "book") public class Book { @Id @GeneratedValue private Long id; @NaturalId private String isbn; private String title; @Type(YearMonthDateType.class) @Column( name = "published_on", columnDefinition = "date" ) private YearMonth publishedOn; }
And for Hibernate 5, like this:
@Entity(name = "Book") @Table(name = "book") @TypeDef(typeClass = YearMonthDateType.class, defaultForType = YearMonth.class) public class Book { @Id @GeneratedValue private Long id; @NaturalId private String isbn; private String title; @Column( name = "published_on", columnDefinition = "date" ) private YearMonth publishedOn; }
Notice the @TypeDef
annotation added at the entity level which instructs Hibernate to use the YearMonthDateType
to handle the YearMonth
entity attributes.
Persisting YearMonth as an Integer using JPA
If you don’t want to store the YearMonth
property as a DATE
, you can use an Integer
-based storage. In this case, you need an AttributeConverted
that looks as follows:
public class YearMonthIntegerAttributeConverter implements AttributeConverter<YearMonth, Integer> { @Override public Integer convertToDatabaseColumn( YearMonth attribute) { if (attribute != null) { return (attribute.getYear() * 100) + attribute.getMonth().getValue(); } return null; } @Override public YearMonth convertToEntityAttribute( Integer dbData) { if (dbData != null) { int year = dbData / 100; int month = dbData % 100; return YearMonth.of(year, month); } return null; } }
Now, we need to add the YearMonthIntegerAttributeConverter
to the YearMonth
property like this:
@Column( name = "published_on", columnDefinition = "mediumint" ) @Convert( converter = YearMonthIntegerAttributeConverter.class ) private YearMonth publishedOn;
Notice that we used the MySQL
MEDIUMINT
column type which only requires 3 bytes, instead of 4 as it’s typical for the INTEGER column type.
Now, when saving the same Book
entity on MySQL, Hibernate will generate the following SQL INSERT statement:
INSERT INTO book ( isbn, published_on, title, id ) VALUES ( '978-9730228236', 201610, 'High-Performance Java Persistence', 1 )
Notice that the 201610
integer value was stored for the associated YearMonth
value.
Persisting YearMonth as an Integer using Hibernate
The same can be done using a Hibernate custom type which looks as follows:
public class YearMonthIntegerType extends AbstractSingleColumnStandardBasicType<YearMonth> { public static final YearMonthIntegerType INSTANCE = new YearMonthIntegerType(); public YearMonthIntegerType() { super( IntegerTypeDescriptor.INSTANCE, YearMonthTypeDescriptor.INSTANCE ); } public String getName() { return "yearmonth-int"; } @Override protected boolean registerUnderJavaType() { return true; } }
The IntegerTypeDescriptor
SQL descriptor is provided by Hibernate while the YearMonthTypeDescriptor
is the same Java class that we introduced when discussing the YearMonthDateType
.
That’s it!
Now, just add the YearMonthIntegerType
mapping to your entity, and you are done.
For Hibernate 6, the mapping will look as follows:
@Entity(name = "Book") @Table(name = "book") public class Book { @Id @GeneratedValue private Long id; @NaturalId private String isbn; private String title; @Type(YearMonthIntegerType.class) @Column( name = "published_on", columnDefinition = "date" ) private YearMonth publishedOn; }
And for Hibernate 5, like this:
@Entity(name = "Book") @Table(name = "book") @TypeDef(typeClass = YearMonthIntegerType.class, defaultForType = YearMonth.class) public class Book { @Id @GeneratedValue private Long id; @NaturalId private String isbn; private String title; @Column( name = "published_on", columnDefinition = "date" ) private YearMonth publishedOn; }
What’s nice about the Hibernate-specific types is that you can easily provide them at the bootstrap time using the hibernate.type_contributors
configuration property:
properties.put("hibernate.type_contributors", (TypeContributorList) () -> Collections.singletonList( (typeContributions, serviceRegistry) -> typeContributions.contributeType( YearMonthIntegerType.INSTANCE ) ));
This way, you no longer need to provide the @TypeDef
annotation, and the YearMonth
attributes will automatically be handled by the YearMonthIntegerType
custom Hibernate type.
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
As you can see, even if Hibernate does not provide a YearMonth
type out-of-the-box, mapping one is not very difficult. The AttributeConverter
works with any JPA provider, while the Hibernate-specific type is very convenient if you add the Hypersistence Utils dependency to your project.
