From 83f90f682d5a443c7c2458a1a675e57d977e7ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Gebhardt?= Date: Fri, 20 Mar 2026 22:12:23 +0100 Subject: [PATCH] =?UTF-8?q?Add=20Envers=20generic=E2=80=91type=20handling?= =?UTF-8?q?=20for=20JSON=20collections=20#838?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (detailed message generated by Claude Opus 4.6) When Hibernate Envers builds audit entity mappings, JavaXMember.getJavaType() returns only the raw Class (e.g. List.class) instead of the full ParameterizedType (e.g. List). This causes Jackson to deserialize JSON collections as LinkedHashMap objects instead of the expected POJO type. This commit applies three fixes to JsonJavaTypeDescriptor: 1. Constructor fix: The (ObjectMapperWrapper, Type) constructor now extracts the raw type from a ParameterizedType before passing it to the superclass, allowing subclasses to provide the full generic type via TypeReference. 2. setParameterValues guard: When propertyType is already a ParameterizedType (e.g. set by a constructor), it is no longer overwritten with a less specific raw Class from Envers. 3. Reflection fallback: When setParameterValues receives only a raw Collection or Map type, the full generic signature is recovered by reflecting on the entity field using DynamicParameterizedType.ENTITY and DynamicParameterizedType.PROPERTY. Root cause: Envers loses generic type information when constructing synthetic JavaXProperty instances for audit entities. This should additionally be reported as a bug against Hibernate ORM / Envers. --- .../json/internal/JsonJavaTypeDescriptor.java | 39 ++- .../PostgreSQLJsonBinaryTypeAuditedTest.java | 228 ++++++++++++++++++ 2 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 hypersistence-utils-hibernate-63/src/test/java/io/hypersistence/utils/hibernate/type/json/PostgreSQLJsonBinaryTypeAuditedTest.java diff --git a/hypersistence-utils-hibernate-63/src/main/java/io/hypersistence/utils/hibernate/type/json/internal/JsonJavaTypeDescriptor.java b/hypersistence-utils-hibernate-63/src/main/java/io/hypersistence/utils/hibernate/type/json/internal/JsonJavaTypeDescriptor.java index 3987beec1..1f34771eb 100644 --- a/hypersistence-utils-hibernate-63/src/main/java/io/hypersistence/utils/hibernate/type/json/internal/JsonJavaTypeDescriptor.java +++ b/hypersistence-utils-hibernate-63/src/main/java/io/hypersistence/utils/hibernate/type/json/internal/JsonJavaTypeDescriptor.java @@ -67,10 +67,12 @@ public JsonJavaTypeDescriptor(final ObjectMapperWrapper objectMapperWrapper) { } public JsonJavaTypeDescriptor(final ObjectMapperWrapper objectMapperWrapper, Type type) { - this((Class) type, objectMapperWrapper); + this(type instanceof ParameterizedType ? (Class) ((ParameterizedType) type).getRawType() : (Class) type, + objectMapperWrapper); setPropertyClass(type); } + @Override public void setParameterValues(Properties parameters) { final XProperty xProperty = (XProperty) parameters.get(DynamicParameterizedType.XPROPERTY); @@ -88,9 +90,44 @@ public void setParameterValues(Properties parameters) { if(type == null) { throw new HibernateException("Could not resolve property type!"); } + + // Don't overwrite a ParameterizedType (e.g. from constructor) + // with a less specific raw Class from Envers. + if (propertyType instanceof ParameterizedType && type instanceof Class) { + return; + } + + // When Envers provides the xproperty for an audit entity, + // JavaXMember.getJavaType() may return only the raw type + // (e.g. List.class) without generic type arguments. + // Recover the full generic signature from the entity's field. + if (type instanceof Class + && (Collection.class.isAssignableFrom((Class) type) + || Map.class.isAssignableFrom((Class) type))) { + + Type resolved = resolveGenericFieldType( + parameters.getProperty(DynamicParameterizedType.ENTITY), + parameters.getProperty(DynamicParameterizedType.PROPERTY) + ); + if (resolved instanceof ParameterizedType) { + type = resolved; + } + } + setPropertyClass(type); } + private Type resolveGenericFieldType(String className, String fieldName) { + if (className == null || fieldName == null) return null; + try { + return ReflectionUtils.getClass(className) + .getDeclaredField(fieldName) + .getGenericType(); + } catch (Exception e) { + return null; + } + } + @Override public boolean areEqual(Object one, Object another) { if (one == another) { diff --git a/hypersistence-utils-hibernate-63/src/test/java/io/hypersistence/utils/hibernate/type/json/PostgreSQLJsonBinaryTypeAuditedTest.java b/hypersistence-utils-hibernate-63/src/test/java/io/hypersistence/utils/hibernate/type/json/PostgreSQLJsonBinaryTypeAuditedTest.java new file mode 100644 index 000000000..2653762c6 --- /dev/null +++ b/hypersistence-utils-hibernate-63/src/test/java/io/hypersistence/utils/hibernate/type/json/PostgreSQLJsonBinaryTypeAuditedTest.java @@ -0,0 +1,228 @@ +package io.hypersistence.utils.hibernate.type.json; + +import com.fasterxml.jackson.core.type.TypeReference; +import io.hypersistence.utils.hibernate.type.model.BaseEntity; +import io.hypersistence.utils.hibernate.util.AbstractPostgreSQLIntegrationTest; +import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.hibernate.annotations.Type; +import org.hibernate.envers.AuditReader; +import org.hibernate.envers.AuditReaderFactory; +import org.hibernate.envers.Audited; +import org.junit.Test; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; + +/** + * @author Vlad Mihalcea + */ +public class PostgreSQLJsonBinaryTypeAuditedTest extends AbstractPostgreSQLIntegrationTest { + + @Override + protected Class[] entities() { + + return new Class[]{User.class}; + } + + private User _user; + + @Override + protected void afterInit() { + + doInJPA(entityManager -> { + User user = new User(); + + user.setId(1L); + user.setPhones(new HashSet<>(asList("7654321", "1234567"))); + user.setAddresses(List.of(new Address("My Road 1"), new Address("Another Street 5"))); + user.setCars(List.of(new Car("Skoda"), new Car("BMW"))); + entityManager.persist(user); + _user = user; + }); + } + + @Test + public void test() { + + doInJPA(entityManager -> { + User user = entityManager.find(User.class, _user.getId()); + assertEquals(new HashSet<>(asList("7654321", "1234567")), user.getPhones()); + assertEquals(Integer.valueOf(0), user.getVersion()); + assertEquals(List.of(new Address("My Road 1"), new Address("Another Street 5")), user.getAddresses()); + assertEquals(List.of(new Car("Skoda"), new Car("BMW")), user.getCars()); + + final Set phones = entityManager.createQuery( + "select phones from " + User.class.getName() + "_AUD where originalId.id=:id", Set.class) + .setParameter("id", _user.getId()) + .getSingleResult(); + assertEquals(new HashSet<>(asList("7654321", "1234567")), phones); + }); + } + + + @Test + public void collectionsOfReferenceTypesCanBeUnmarshalledInTheAuditReader() { + + doInJPA(entityManager -> { + AuditReader auditReader = AuditReaderFactory.get(entityManager); + + List revisions = auditReader.getRevisions(User.class, _user.getId()); + assertEquals(1, revisions.size()); + User rev1 = auditReader.find(User.class, _user.getId(), revisions.get(0)); + assertEquals("My Road 1", rev1.getAddresses().get(0).getStreet()); + assertEquals("Another Street 5", rev1.getAddresses().get(1).getStreet()); + assertEquals("Skoda", rev1.getCars().get(0).getBrand()); + assertEquals("BMW", rev1.getCars().get(1).getBrand()); + }); + } + + @Test + public void testLoad() { + + SQLStatementCountValidator.reset(); + + doInJPA(entityManager -> { + User user = entityManager.find(User.class, _user.getId()); + assertEquals(new HashSet<>(asList("1234567", "7654321")), user.getPhones()); + assertEquals(Integer.valueOf(0), user.getVersion()); + }); + + SQLStatementCountValidator.assertTotalCount(1); + SQLStatementCountValidator.assertSelectCount(1); + SQLStatementCountValidator.assertUpdateCount(0); + } + + public static class CarListJsonBinaryType extends JsonBinaryType { + public CarListJsonBinaryType() { + super(new TypeReference>() {}.getType()); + } + } + + public static class Car implements Serializable { + private String brand; + + public Car() {} + + public Car(String brand) { + this.brand = brand; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + Car car = (Car) o; + return Objects.equals(brand, car.brand); + } + + @Override + public int hashCode() { + return Objects.hashCode(brand); + } + } + + + + public static class Address implements Serializable { + private String street; + + public Address() {} + + public Address(String street) { + this.street = street; + } + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + Address address = (Address) o; + return Objects.equals(street, address.street); + } + + @Override + public int hashCode() { + return Objects.hashCode(street); + } + } + + + @Entity(name = "User") + @Table(name = "users") + @Audited + public static class User extends BaseEntity { + + private String name; + + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb") + private Set phones; + + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb") + private List
addresses; + + + @Type(CarListJsonBinaryType.class) + @Column(columnDefinition = "jsonb") + private List cars; + + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Set getPhones() { + return phones; + } + + public void setPhones(Set phones) { + this.phones = phones; + } + + public List
getAddresses() { + return addresses; + } + + public void setAddresses(List
addresses) { + this.addresses = addresses; + } + + public List getCars() { + return cars; + } + + public void setCars(List cars) { + this.cars = cars; + } + } +}