JPA/Hibernate Map using key java.time.YearMonth

Viewed 271

I am working on a financial application where the unit of time is the month, intended as (year, month). FTT stands for Financial Transaction Tax, to give a bit of a context

Our company promotes a clean-database-first approach. We use Hibernate 5.1. I know it's EOL, but can't be changed.

I am currently refactoring an entity

@Entity
@Table(name="FTT_REPORT")
public class FttReport {

    @Column(name="REPORT_ID")
    private final Long id = null;

    @Column(name="REFERENCE_YEAR")
    private int referenceYear; //Old code, I could use YearMonth directly
    @Column(name="REFERENCE_MONTH")
    private int referenceMonth; //Old code, I could use YearMonth directly


    private BigDecimal [a lot of tax figures];

    @ElementCollection(fetch = FetchType.EAGER)
    @MapKeyClass(YearMonth.class)
    @OrderBy("REFERENCE_YEAR, REFERENCE_MONTH")
    // @MapKeyType(@Type(type = "com.acme.FttYearMonthUserType")) #I'll explain soon why it's commented out
    @AttributeOverrides({//
        @AttributeOverride(name = "key.year", column = @Column(name = "REFERENCE_YEAR")),//
        @AttributeOverride(name = "key.month", column = @Column(name = "REFERENCE_MONTH")),//
    })
    @CollectionTable(//
        name = "FTT_REPORT_ADJUSTMENTS",//
        foreignKey = @ForeignKey(value = ConstraintMode.CONSTRAINT, name = "FK_FTT_ADJUSTMENT_REPORT"),//
        joinColumns = @JoinColumn(name = "REPORT_ID")//
    )
    private final SortedMap<YearMonth, FttAdjustment> adjustments = new TreeMap<>();

}

The FttAdjustment is just an embeddable with only numerical columns

A brief explanation. Our application computes tax reports every month, but sometimes late trades come in and Customer has to pay a penalty fee /surcharge tax to Government. We don't blend the figure into total tax but need to show the user a full report of all surcharge taxes for all "adjusted" months.

At this point, I know that I could call it a day by using LocalDate as map key and assume day always 1st.

But I am required to try a cleaner approach first.

With the above mapping, the following is successful:

  • Hibernate correctly creates the database creation script with the correct columns that I like (without @MapKeyType)
  • Hibernate successfully adds the expected primary key and foreign key constraints (without @MapKeyType)
  • Hibernate successfully INSERTs the very first tax adjustment from the unit test

What doesn't work, is retrieval. When Hibernate retrieves the first FttReport tested for adjustments. At this moment, MapKeyType is still commented out.

Caused by: org.hibernate.InstantiationException: No default constructor for entity:  : java.time.YearMonth
    at org.hibernate.tuple.PojoInstantiator.instantiate(PojoInstantiator.java:81) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.tuple.component.AbstractComponentTuplizer.instantiate(AbstractComponentTuplizer.java:83) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.type.ComponentType.instantiate(ComponentType.java:577) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.type.ComponentType.instantiate(ComponentType.java:583) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.type.ComponentType.resolve(ComponentType.java:681) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.type.ComponentType.nullSafeGet(ComponentType.java:325) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.persister.collection.AbstractCollectionPersister.readIndex(AbstractCollectionPersister.java:845) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.collection.internal.PersistentMap.readFrom(PersistentMap.java:265) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.plan.exec.process.internal.CollectionReferenceInitializerImpl.finishUpRow(CollectionReferenceInitializerImpl.java:77) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.plan.exec.process.internal.AbstractRowReader.readRow(AbstractRowReader.java:121) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.plan.exec.process.internal.ResultSetProcessorImpl.extractResults(ResultSetProcessorImpl.java:122) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.plan.exec.internal.AbstractLoadPlanBasedLoader.executeLoad(AbstractLoadPlanBasedLoader.java:122) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.plan.exec.internal.AbstractLoadPlanBasedLoader.executeLoad(AbstractLoadPlanBasedLoader.java:86) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.collection.plan.AbstractLoadPlanBasedCollectionInitializer.initialize(AbstractLoadPlanBasedCollectionInitializer.java:88) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.persister.collection.AbstractCollectionPersister.initialize(AbstractCollectionPersister.java:688) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.event.internal.DefaultInitializeCollectionEventListener.onInitializeCollection(DefaultInitializeCollectionEventListener.java:75) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.internal.SessionImpl.initializeCollection(SessionImpl.java:2004) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.collection.internal.AbstractPersistentCollection$4.doWork(AbstractPersistentCollection.java:567) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.collection.internal.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:249) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.collection.internal.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:563) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.collection.internal.AbstractPersistentCollection.forceInitialization(AbstractPersistentCollection.java:731) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.engine.internal.StatefulPersistenceContext.initializeNonLazyCollections(StatefulPersistenceContext.java:918) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.Loader.doQueryAndInitializeNonLazyCollections(Loader.java:347) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.Loader.doList(Loader.java:2622) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.Loader.doList(Loader.java:2605) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.Loader.listIgnoreQueryCache(Loader.java:2434) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.Loader.list(Loader.java:2429) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.loader.hql.QueryLoader.list(QueryLoader.java:501) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.hql.internal.ast.QueryTranslatorImpl.list(QueryTranslatorImpl.java:370) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.engine.query.spi.HQLQueryPlan.performList(HQLQueryPlan.java:216) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.internal.SessionImpl.list(SessionImpl.java:1339) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at org.hibernate.internal.QueryImpl.list(QueryImpl.java:87) ~[hibernate-core-5.1.17.Final.jar:5.1.17.Final]
    at com.acme.core.data.dao.hibernate.BaseDaoImpl.findAll(BaseDaoImpl.java:927) ~[acme-3.10.5-BETA-15.jar:47cac235aaafde97a5117a64431433f405db8026]

So far so bad. Other entities, like a Trade use YearMonth columns flat-ly by using a custom type

@MappedSuperclass
public abstract class FttAbstractTrade {

    @Type(type = "com.acme.FttYearMonthUserType")
    @Columns(columns = {//
        @Column(name = "REFERENCE_YEAR"),//
        @Column(name = "REFERENCE_MONTH")//
    })
    protected YearMonth referencePeriod;

}

At this point, please note that @MapKeyType was commented out, so I had tried to enable that annotation with custom type. At least, I thought, the custom type handler knows that a YearMonth must be instantiated via constructor with year and month.

Cool, but now Hibernate won't map the REFERENCE_YEAR and REFERENCE_MONTH columns, trying to find basic year and month columns.

I know the application will work if I accept Hibernate's mandated column names, but I have a little more time to investigate and learn before this becomes a total showstopper.

After enabling the MapKeyType annotation, startup error occurs (because hbm2ddl.auto is validate)

org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validation: missing column [month] in table [FTT_REPORT_ADJUSTMENTS]

Question

Well... basically I could ask "how can I make this thing work?" but a good phrasing is...

  • What is the correct way to use an unconventional complex type as key in a map? By unconventional, I mean a type that is not natively registered with Hibernate (neither in JSR-310 extensions) and has no POJO format
  • Why, when I enable MapKeyType, my AttributeOverrides annotations get totally ignored? I expected to override the columns
  • Is there a better way to tell Hibernate that I want this multi-key map to name its columns my preferred way?

Edit

I have fixed my startup problem by changing AttributeOverrides.

It took me debugging into Hibernate's own source code to find how it liked the attribute to be named.

By setting a breakpoint in Hibernate's source code (AbstractPropertyHolder line 264) I found out that Hibernate uses keyword index in this case to identify map key.

I changed

@AttributeOverrides({//
     @AttributeOverride(name = "index.year", column = @Column(name = "REFERENCE_YEAR")),//
    @AttributeOverride(name = "index.month", column = @Column(name = "REFERENCE_MONTH")),//
})

Now the application starts but I still get the instantiation exception

Edit

Soure code for the user type, FttYearMonthUserType

public class FttYearMonthUserType implements UserType
{

    @Override
    public int[] sqlTypes()
    {
        return new int[] { Types.INTEGER, Types.INTEGER };
    }

    @Override
    public Class returnedClass()
    {
        return YearMonth.class;
    }

    @Override
    public boolean equals(Object x, Object y) throws HibernateException
    {
        return Objects.equals(x, y);
    }

    @Override
    public int hashCode(Object x) throws HibernateException
    {
        return Objects.hashCode(x);
    }

    @Override
    public YearMonth nullSafeGet(ResultSet rs, String[] names, SessionImplementor session, Object owner) throws HibernateException, SQLException
    {

        int year = rs.getInt(names[0]);
        if (rs.wasNull())
            return null;
        int month = rs.getInt(names[1]);
        if (rs.wasNull())
            return null;

        return YearMonth.of(year, month);
    }

    @Override
    public void nullSafeSet(PreparedStatement st, Object value, int index, SessionImplementor session) throws HibernateException, SQLException
    {
        if (Objects.isNull(value))
        {
            st.setNull(index, Types.INTEGER);
            st.setNull(index + 1, Types.INTEGER);
        }
        else
        {
            YearMonth yearMonth = (YearMonth) value;
            Integer year = yearMonth.getYear();
            Integer month = yearMonth.getMonthValue();

            st.setObject(index, year, Types.INTEGER);
            st.setObject(index + 1, month, Types.INTEGER);
        }
    }

    @Override
    public YearMonth deepCopy(Object value) throws HibernateException
    {
        if (value == null)
            return null;
        YearMonth yearMonth = (YearMonth) value;
        return YearMonth.of(yearMonth.getYear(), yearMonth.getMonthValue());
    }

    @Override
    public boolean isMutable()
    {
        return false; //Fixed after conversation
    }

    @Override
    public Serializable disassemble(Object value) throws HibernateException
    {
        return deepCopy(value);
    }

    @Override
    public Object assemble(Serializable cached, Object owner) throws HibernateException
    {
        return deepCopy(cached);
    }

    @Override
    public Object replace(Object original, Object target, Object owner) throws HibernateException
    {
        return deepCopy(original);
    }

}

Edit

After more debugging into Hibernate's core, I discovered what follows

On AbstractCollectionPersister constructor I debugged the path indexedCollection.getIndex().getType() and it is null. It must be a Type. UserType does not extend Type while CompositeType does, and that's not even CompositeUserType.

The statement returns a default ComponentType which I am examining

IntelliJ debug window

For now I have understood that during the SessionFactory init phase, the property of that key should change. Or, judging by the type of persisters that Hibernate offers out of the box, maybe it's not possible to use a non-POJO type as map key, and perhaps one might need to use the value as store for the key's columns?

1 Answers

I see very similar mapping in the hibernate documentation, but with one important difference: map key mapped to a single column. I can not find proof in the documentation, but I am afraid the similar mapping with map key mapped to the several columns is not possible for the @ElementCollection association.

However, I was able to do it for the @OneToMany association. So, below I will provide an example.

Assuming that we have the following schema:

create table FTT_REPORT
(
   REPORT_ID int not null,
   primary key (REPORT_ID)
);

create table FTT_REPORT_ADJUSTMENTS
(
   REPADJ_ID int not null,
   REPADJ_REPORT_ID int not null,
   REPADJ_YEAR int,
   REPADJ_MONTH int,
   
   primary key (REPADJ_ID),
   foreign key (REPADJ_REPORT_ID) references FTT_REPORT(REPORT_ID)
);

We can use the following mapping:

@Entity
@Table(name="FTT_REPORT")
public class FttReport
{
   @Id
   @Column(name="REPORT_ID")
   private Long id;

   @OneToMany(cascade = CascadeType.ALL)
   @OrderBy("REPADJ_YEAR, REPADJ_MONTH")
   @JoinColumn(name = "REPADJ_REPORT_ID", updatable = false)
   @MapKey(name = "adjYearMonth")
   private SortedMap<YearMonth, FttAdjustment> adjustments;
}


@Entity
@Table(name = "FTT_REPORT_ADJUSTMENTS")
public class FttAdjustment
{
   @Id
   @Column(name = "REPADJ_ID")
   private Long id;
   
   // this needs only for adding new FttAdjustment
   @Column(name = "REPADJ_REPORT_ID")
   private Long reportId;
   
   @Type(type = "com.acme.FttYearMonthUserType")
   @Columns(columns = {
      @Column(name = "REPADJ_YEAR"),
      @Column(name = "REPADJ_MONTH")
   })
   private YearMonth adjYearMonth;
}
  1. @MapKey annotation can be used only if the FttAdjustment is an entity.
  2. The updatable = false was added to the @JoinColumn to avoid extra update query during inserting new FttAdjustment.
  3. This mapping was tested with hibernate 5.4.10.Final.

P.S. And one more additional note. The @AttributeOverride is the JPA defined annotation for the overriding embeddable types column names, but you try to use it for setting column names for the hibernate custom basic types. I am afraid that this is a mistake.

Related