How to map java.time Year and Month 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 the java.time.Year
and java.time.Month
with both JPA and Hibernate.
As explained in this article, JPA 2.2 supports LocalDate
, LocalTime
, LocalDateTime
, OffsetTime
, OffsetDateTime
from the java.time
package. Hibernate has been supporting the Java 8 Date/Time classes since 5.0 via the hibernate-java8
dependency, but since version 5.2, this dependency was merged with hibernate-core
so you get the Duration
, Instant
and ZonedDateTime
types in addition to the ones supported by JPA 2.2.
However, neither JPA nor Hibernate supports the java.time.Year
type out-of-the-box. As you will see, adding support for java.time.Year
is very easy with both standard JPA or Hibernate.
Domain Model
Let’s assume we have the following Publisher
entity which defines an estYear
property of type java.time.Year
and a salesMonth
property of the type java.time.Month
:
Since the java.time.Month
is a Java Enum, we can use the @Enumarated
annotation to instruct Hibernate to handle this property as an enumeration. If you want to persist the property in an Integer
column, you don’t need to specify the javax.persistence.EnumType.ORDINAL
since this is the default strategy. However, if you wish to persist the Enum as a String
column type, you need to use the @Enumarated(EnumType.STRING)
annotation instead.
Because we want to persist the salesMonth
in a smallint
column, we can map this property like this:
@Column( name = "sales_month", columnDefinition = "smallint" ) @Enumerated private Month salesMonth;
Since, by default, Hibernate does not support the java.time.Year
object type, we will have to instruct Hibernate how to handle the estYear
property, and this can be done either via a JPA AttributeConverter
or using a Hibernate-specific type.
Persisting java.time.Year using JPA
When using JPA, we can use the AttributeConverter
interface to define the conversion logic between the Java Year
type and the integer-based column type.
@Converter(autoApply = true) public class YearAttributeConverter implements AttributeConverter<Year, Short> { @Override public Short convertToDatabaseColumn( Year attribute) { if (attribute != null) { return (short) attribute.getValue(); } return null; } @Override public Year convertToEntityAttribute( Short dbData) { if (dbData != null) { return Year.of(dbData); } return null; } }
We can use Short
instead of Integer
since we chose the smallint
column type on the database side which takes 2 bytes instead of 4 and takes numeric values from -32768 to 32767 which is sufficient for most applications that need to persist a java.time.Year
property.
Now you can instruct Hibernate to use the YearAttributeConverter
using the @Convert
annotation, and the Publisher
entity will look as follows:
@Entity(name = "Publisher") @Table(name = "publisher") public class Publisher { @Id @GeneratedValue private Long id; @NaturalId private String name; @Column( name = "est_year", columnDefinition = "smallint" ) @Convert( converter = YearAttributeConverter.class ) private Year estYear; @Column( name = "sales_month", columnDefinition = "smallint" ) @Enumerated private Month salesMonth; //Getters and setters omitted for brevity }
When persisting a the following Publisher
entity on MySQL:
Publisher publisher = new Publisher(); publisher.setName("vladmihalcea.com"); publisher.setEstYear(Year.of(2013)); publisher.setSalesMonth(Month.NOVEMBER); entityManager.persist(publisher);
Hibernate generates the following SQL INSERT statement:
INSERT INTO publisher ( est_year, name, sales_month, id ) VALUES ( 2013, 'vladmihalcea.com', 10, 1 )
Notice the ‘2013’ value used for the est_year
column and the 10
value used for Month.NOVEMBER
for the sales_month
column.
And when fetching the Publisher
entity:
Publisher publisher = entityManager .unwrap(Session.class) .bySimpleNaturalId(Publisher.class) .load("vladmihalcea.com"); assertEquals( Year.of(2013), publisher.getEstYear() ); assertEquals( Month.NOVEMBER, publisher.getSalesMonth() );
We can see that the estYear
and salesMonth
properties are properly set by Hibernate.
Persisting java.time.Year using Hibernate
You can achieve the same goal using a Hibernate custom type:
public class YearType extends AbstractSingleColumnStandardBasicType<Year> { public static final YearType INSTANCE = new YearType(); public YearType() { super( SmallIntTypeDescriptor.INSTANCE, YearTypeDescriptor.INSTANCE ); } public String getName() { return "year"; } @Override protected boolean registerUnderJavaType() { return true; } }
While the SmallIntTypeDescriptor
is a Hibernate SQL descriptor provided via the hibernate-core
dependency, the YearTypeDescriptor
is a class that I wrote for myself and looks as follows:
public class YearTypeDescriptor extends AbstractTypeDescriptor<Year> { public static final YearTypeDescriptor INSTANCE = new YearTypeDescriptor(); public YearTypeDescriptor() { super(Year.class); } @Override public boolean areEqual( Year one, Year another) { return Objects.equals(one, another); } @Override public String toString( Year value) { return value.toString(); } @Override public Year fromString( String string) { return Year.parse(string); } @SuppressWarnings({"unchecked"}) @Override public <X> X unwrap( Year 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)) { Short numericValue = (short) value.getValue(); return (X) (numericValue); } throw unknownUnwrap(type); } @Override public <X> Year wrap( X value, WrapperOptions options) { if (value == null) { return null; } if (value instanceof String) { return fromString((String) value); } if (value instanceof Number) { short numericValue = ((Number) (value)).shortValue(); return Year.of(numericValue); } 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 YearType
in place, we only need to add it to the entity mapping.
For Hibernate 6, the mapping will look as follows:
@Entity(name = "Publisher") @Table(name = "publisher") public class Publisher { @Id @GeneratedValue private Long id; @NaturalId private String name; @Type(YearType.class) @Column( name = "est_year", columnDefinition = "smallint" ) private Year estYear; @Column( name = "sales_month", columnDefinition = "smallint" ) @Enumerated private Month salesMonth; }
And for Hibernate 5, like this:
@Entity(name = "Publisher") @Table(name = "publisher") @TypeDef(typeClass = YearType.class, defaultForType = Year.class) public class Publisher { @Id @GeneratedValue private Long id; @NaturalId private String name; @Column( name = "est_year", columnDefinition = "smallint" ) private Year estYear; @Column( name = "sales_month", columnDefinition = "smallint" ) @Enumerated private Month salesMonth; }
The @Type
and @TypeDef
annotations instruct Hibernate to use the YearType
to handle the Year
entity attributes.
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 Year
or a Month
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.
