I’m migrating my UserType
class from Hibernate 5 to 6. As far as I understand the rules, I should use CompositeUserType
because my mapped class is rather complex and encapsulates several properties to be persisted. This can’t be accomplished by UserType
according to Hibernate 6’s JavaDoc. For the most part, it works. But there is one particular scenario where it can’t handle the cascading merge; I got a NullPointerException
, as follows:
java.lang.NullPointerException: Cannot invoke “org.hibernate.property.access.spi.Setter.set(Object, Object)” because the return value of “org.hibernate.property.access.spi.PropertyAccess.getSetter()” is null
See entire exception stack trace
I created a minimal sample project to reproduce the problem. Please advise how to fix this issue.
For your convenience, I excerpt the important code from my sample project for a quick read.
Here is my VaultValue
type encapsulating a piece of data securely stored in a Vault:
public class VaultValue {
protected String value = null; // plaintext to be persisted into a secure Vault storage
protected Instant date = null; // the date when plaintext was persisted into the secure Vault storage
protected Long vaultId = null; // an opaque id used to reference the plaintext persisted in the secure Vault storage
protected String hashValue = null; // a hash value calculated based on the plaintext
public VaultValue() {}
public VaultValue(Long vaultId, String hashValue) {
this.vaultId = vaultId;
this.hashValue = hashValue;
}
public VaultValue(VaultValue src) {
this.vaultId = src.vaultId;
this.hashValue = src.hashValue;
this.value = src.value;
this.date = src.date;
}
public Long getVaultId() {
return this.vaultId;
}
public String getHashValue() {
return this.hashValue;
}
private boolean setValue(String value, Instant date) {
final var newHash = hash(value);
final boolean changed = !StringUtils.equals(this.hashValue, newHash);
this.value = value;
this.date = date;
this.hashValue = newHash;
return changed;
}
public static VaultValue setValue(VaultValue vaultValue, String value, Instant date) {
VaultValue retval = (vaultValue == null ? new VaultValue() : vaultValue);
retval.setValue(value, date);
return retval;
}
public static String hash(String value) {
if (value == null)
return "";
return DigestUtils.sha256Hex(value.getBytes());
}
@Override public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof VaultValue)) return false;
VaultValue other = (VaultValue)o;
// For use by Hibernate dirty-checking, it is important that this test only the
// values that are persisted in the database: vaultId and hashValue.
if (!Objects.equals(this.vaultId, other.vaultId)) return false;
if (!Objects.equals(this.hashValue, other.hashValue)) return false;
return true;
}
@Override public int hashCode() {
return Objects.hashCode(this.vaultId);
}
}
Here is my CompositeUserType
class that maps the VaultValue
type:
public class VaultType implements CompositeUserType<VaultValue> {
public static class EmbeddableMapper {
String hash;
Long id;
}
@Override
public Object getPropertyValue(VaultValue component, int property) throws HibernateException {
return switch (property) { // Hibernate sorts the properties by their names alphabetically
case 0 -> component.getHashValue();
case 1 -> component.getVaultId();
default -> null;
};
}
@Override
public VaultValue instantiate(ValueAccess values, SessionFactoryImplementor sessionFactory) {
final String hash = values.getValue(0, String.class);
final Long id = values.getValue(1, Long.class);
return new VaultValue(id, hash);
}
@Override public Class<?> embeddable() { return EmbeddableMapper.class; }
@Override public Class<VaultValue> returnedClass() { return VaultValue.class; }
@Override public boolean equals(VaultValue x, VaultValue y) { return Objects.equals(x, y); }
@Override public int hashCode(VaultValue x) { return Objects.hashCode(x); }
@Override public boolean isMutable() { return true; } // Yes, the VaultValue internal state may change
@Override public VaultValue deepCopy(VaultValue value) {
if (value == null) return null;
return new VaultValue(value);
}
@Override public Serializable disassemble(VaultValue value) { throw new UnsupportedOperationException(); }
@Override public VaultValue assemble(Serializable cached, Object owner) { throw new UnsupportedOperationException(); }
@Override public VaultValue replace(VaultValue detached, VaultValue managed, Object owner) {
return deepCopy(detached);
}
}
In my model, I have two entity classes:
- Encounter - an encounter in a healthcare setting
- Observation - a physician’s observation of the patient
An Encounter has a collection of Observations; basically, Encounter and Observation have a cascade one-to-many relation. Both the Encounter entity and the Observation entity have a property of my VaultValue
class.
By and large, they seem to work with Hibernate 6.1.6 (via Spring Boot 3.0.1). However, when an Encounter instance is re-saved after some new Observation instances are added to it, I get NullPointerException
caused by PropertyAccessCompositeUserTypeImpl::getSetter()
which always returns null
.
Here is the Observation
entity class:
@Entity
public class Observation extends IdObject {
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "id_encounter", nullable = false, updatable = false, foreignKey = @ForeignKey(name = "FK_observation_has_encounter"))
private Encounter encounter;
public void setEncounter(Encounter encounter) {
this.encounter = encounter;
}
public Encounter getEncounter() {
return this.encounter;
}
@Embedded
@AttributeOverride(name = "id", column = @Column(name = "secure_value_id"))
@AttributeOverride(name = "hash", column = @Column(name = "secure_value_hash"))
@CompositeType(VaultType.class)
private VaultValue secureValue = new VaultValue();
public void setSecureValue(VaultValue secureValue) {
this.secureValue = secureValue;
}
public void setSecureValue(String value, Instant referenceDate) {
secureValue = VaultValue.setValue(secureValue, StringUtils.trimToNull(value), referenceDate);
}
public VaultValue getSecureValue() {
return this.secureValue;
}
}
Here is the Encounter
entity class:
@Entity
public class Encounter extends IdObject {
@Embedded
@AttributeOverride(name = "id", column = @Column(name = "identifier_id"))
@AttributeOverride(name = "hash", column = @Column(name = "identifier_hash"))
@CompositeType(VaultType.class)
private VaultValue identifier = new VaultValue();
public void setIdentifier(String rawValue, Instant referenceDate) {
String value = StringUtils.trimToNull(rawValue);
identifier = VaultValue.setValue(identifier, value, referenceDate);
}
public VaultValue getIdentifier() {
return this.identifier;
}
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "encounter")
private Set<Observation> observations = new HashSet<>();
public Set<Observation> getObservations() {
return this.observations;
}
public void setObservations(Set<Observation> observations) {
this.observations = observations;
}
}
The following is the test code that reproduces the NullPointerException
(i.e. the Test Case 2 in HibernateApplicationTests
code in my minimal sample project, see the link above):
@Test
void saveEncounterWithoutSavingObservationsThenSaveEncounterAgain_NOT_OK() {
// Create and save an Encounter.
final Encounter encounter = new Encounter();
encounter.setIdentifier("Medical-Record-Number-123456", Instant.now());
mockPersistToVault(encounter.getIdentifier());
encounterRepository.save(encounter);
// Create and add an Observation to the Encounter.
final Observation observation = new Observation();
observation.setEncounter(encounter);
encounter.getObservations().add(observation);
observation.setSecureValue("triage notes including patient's private information blah blah blah...", Instant.now());
mockPersistToVault(observation.getSecureValue());
// Attempt re-saving the Encounter in order to propagate saving of Observations via one-to-many cascade.
assertThatThrownBy(() -> encounterRepository.save(encounter))
.isInstanceOf(NullPointerException.class); // Why does PropertyAccessCompositeUserTypeImpl::getSetter() return null?
/* encounterRepository.save(encounter); */ // desired
/* assertThat(observation.getId()).isNotNull(); */ // desired
}
Note:
encounterRepository
is an injected Spring JPA RepositorymockPersistToVault()
just assignsvaultId
an arbitrary number for theVaultValue
argument
I could work around it by saving every Observation
in the collection first before re-saving the Encounter
. However, this shouldn’t be necessary because I set up cascade for the one-to-many relation. In fact, I didn’t need to do so with Hibernate 5.6.14.
Please advise. Thank you!