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, myAttributeOverrides 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
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?
