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:
{quote}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{quote}
See [entire exception stack trace|https://github.com/sahawut/hibernate6-issue/blob/master/Test%20case%202%20(desired%20code%20entire%20stack%20trace).txt]
I created a [minimal sample project|https://github.com/sahawut/hibernate6-issue] to reproduce the problem. [ @beikov identified it as a bug |https://discourse . hibernate.org/t/npe-when-using-compositeusertype-in-hibernate-6/7121/2?u=sahawut].
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:
{noformat}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); } } {noformat}
Here is my {{CompositeUserType}} class that maps the {{VaultValue}} type:
{noformat}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); } } {noformat}
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:
{noformat}@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; } } {noformat}
Here is the {{Encounter}} entity class:
{noformat}@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; } } {noformat}
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):
{noformat}@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 } {noformat}
Note:
* {{encounterRepository}} is an injected Spring JPA Repository * {{mockPersistToVault()}} just assigns {{vaultId}} an arbitrary number for the {{VaultValue}} 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. |
|