NPE when using CompositeUserType in Hibernate 6

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 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.

Please advise. Thank you!

Hi there. Thanks for the detailed report and reproducer. This is in fact a bug. Please create a JIRA issue for this and attach all this information there.

I copied the content of this post to [HHH-16015] - Hibernate JIRA.