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.

Domain Model

Let’s consider we have the following Book entity:

YearMonth entity mapping

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.

Seize the deal! 40% discount. Seize the deal! 40% discount.

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.

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.