Support Map<Enum,Object> in @DbJson(B) fields

Addresses #3735. Enum-keyed JSON maps previously failed (enum key cast to
 String). Add ScalarTypeJsonMapEnum which converts enum keys via their
 ScalarType (honouring @DbEnumValue) and reuses ScalarTypeJsonMap for all
 storage/platform handling, so it works for VARCHAR/CLOB/BLOB and Postgres
 JSON/JSONB with no per-storage variants.

 - JsonStorage now reports jdbcType(), enabling a shared storageFor(...) reused
   by the plain and enum-key Map types
 - DefaultTypeManager routes Map<Enum,Object> to the new type and calls
   setAccessible on the @DbEnumValue method (supports nested/non-public enums)
 - add TypeReflectHelper.getMapKeyTypeRaw and TestEnumKeyMap
This commit is contained in:
robin.bygrave
2026-06-19 20:03:27 +12:00
parent bffe741642
commit 10bf5dbbbd
6 changed files with 280 additions and 12 deletions
@@ -320,6 +320,10 @@ public final class DefaultTypeManager implements TypeManager {
return ScalarTypeJsonSet.typeFor(postgres, dbType, docType(genericType), prop.isNullable(), keepSource(prop));
}
if (type.equals(Map.class) && isMapValueTypeObject(genericType)) {
Type keyType = TypeReflectHelper.getMapKeyTypeRaw(genericType);
if (isEnumType(keyType)) {
return enumJsonMapType(postgres, dbType, keyType, keepSource(prop));
}
return ScalarTypeJsonMap.typeFor(postgres, dbType, keepSource(prop));
}
if (objectMapperPresent && prop.getMutationDetection() == MutationDetection.DEFAULT) {
@@ -338,6 +342,13 @@ public final class DefaultTypeManager implements TypeManager {
return prop.getMutationDetection() == MutationDetection.SOURCE;
}
@SuppressWarnings("unchecked")
private ScalarType<?> enumJsonMapType(boolean postgres, int dbType, Type keyType, boolean keepSource) {
Class<? extends Enum<?>> enumClass = asEnumClass(keyType);
ScalarType<? extends Enum<?>> enumScalarType = (ScalarType<? extends Enum<?>>) enumType(enumClass, null);
return ScalarTypeJsonMapEnum.typeFor(postgres, dbType, enumScalarType, keepSource);
}
private DocPropertyType docPropertyType(DeployBeanProperty prop, Class<?> type) {
return type.equals(List.class) || type.equals(Set.class) ? docType(prop.getGenericType()) : DocPropertyType.OBJECT;
}
@@ -560,6 +571,7 @@ public final class DefaultTypeManager implements TypeManager {
*/
private ScalarTypeEnum<?> enumTypeDbValue(Class<? extends Enum<?>> enumType, Method method, boolean integerType, int length, boolean withConstraint) {
Map<String, String> nameValueMap = new LinkedHashMap<>();
method.setAccessible(true);
for (Enum<?> enumConstant : enumType.getEnumConstants()) {
try {
Object value = method.invoke(enumConstant);
@@ -1,5 +1,6 @@
package io.ebeaninternal.server.type;
import io.ebean.config.dbplatform.DbPlatformType;
import io.ebean.core.type.DataBinder;
import io.ebean.core.type.DataReader;
import io.ebean.core.type.PostgresHelper;
@@ -38,6 +39,11 @@ interface JsonStorage {
*/
String read(DataReader reader) throws SQLException;
/**
* The JDBC type reported by a ScalarType using this storage.
*/
int jdbcType();
/**
* Bind the given non-null raw JSON content.
*/
@@ -54,6 +60,11 @@ interface JsonStorage {
return reader.getString();
}
@Override
public int jdbcType() {
return Types.VARCHAR;
}
@Override
public void bind(DataBinder binder, String rawJson) throws SQLException {
binder.setString(rawJson);
@@ -71,6 +82,11 @@ interface JsonStorage {
return reader.getStringFromStream();
}
@Override
public int jdbcType() {
return Types.CLOB;
}
@Override
public void bind(DataBinder binder, String rawJson) throws SQLException {
binder.setString(rawJson);
@@ -102,6 +118,11 @@ interface JsonStorage {
}
}
@Override
public int jdbcType() {
return Types.BLOB;
}
@Override
public void bind(DataBinder binder, String rawJson) throws SQLException {
binder.setBytes(rawJson.getBytes(StandardCharsets.UTF_8));
@@ -115,9 +136,11 @@ interface JsonStorage {
final class Postgres implements JsonStorage {
private final String pgType;
private final int jdbcType;
Postgres(String pgType) {
this.pgType = pgType;
this.jdbcType = PostgresHelper.JSONB_TYPE.equals(pgType) ? DbPlatformType.JSONB : DbPlatformType.JSON;
}
@Override
@@ -125,6 +148,11 @@ interface JsonStorage {
return reader.getString();
}
@Override
public int jdbcType() {
return jdbcType;
}
@Override
public void bind(DataBinder binder, String rawJson) throws SQLException {
binder.setObject(PostgresHelper.asObject(pgType, rawJson));
@@ -17,34 +17,38 @@ import java.util.Map;
* or Postgres JSON / JSONB.
*/
@SuppressWarnings("rawtypes")
final class ScalarTypeJsonMap extends ScalarTypeJsonValue<Map> {
class ScalarTypeJsonMap extends ScalarTypeJsonValue<Map> {
/**
* Return the ScalarType for the requested dbType and platform.
*/
static ScalarTypeJsonMap typeFor(boolean postgres, int dbType, boolean keepSource) {
return new ScalarTypeJsonMap(storageFor(postgres, dbType), keepSource);
}
/**
* Select the storage strategy for the given dbType and platform. Shared with the
* enum-key Map variant.
*/
static JsonStorage storageFor(boolean postgres, int dbType) {
switch (dbType) {
case Types.VARCHAR:
return new ScalarTypeJsonMap(Types.VARCHAR, JsonStorage.VARCHAR, keepSource);
return JsonStorage.VARCHAR;
case Types.BLOB:
return new ScalarTypeJsonMap(Types.BLOB, JsonStorage.BLOB, keepSource);
return JsonStorage.BLOB;
case Types.CLOB:
return new ScalarTypeJsonMap(Types.CLOB, JsonStorage.CLOB, keepSource);
return JsonStorage.CLOB;
case DbPlatformType.JSONB:
return postgres
? new ScalarTypeJsonMap(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), keepSource)
: new ScalarTypeJsonMap(Types.CLOB, JsonStorage.CLOB, keepSource);
return postgres ? JsonStorage.postgres(PostgresHelper.JSONB_TYPE) : JsonStorage.CLOB;
case DbPlatformType.JSON:
return postgres
? new ScalarTypeJsonMap(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), keepSource)
: new ScalarTypeJsonMap(Types.CLOB, JsonStorage.CLOB, keepSource);
return postgres ? JsonStorage.postgres(PostgresHelper.JSON_TYPE) : JsonStorage.CLOB;
default:
throw new IllegalStateException("Unknown dbType " + dbType);
}
}
private ScalarTypeJsonMap(int jdbcType, JsonStorage storage, boolean keepSource) {
super(Map.class, jdbcType, storage, keepSource, true, null, DocPropertyType.OBJECT);
ScalarTypeJsonMap(JsonStorage storage, boolean keepSource) {
super(Map.class, storage.jdbcType(), storage, keepSource, true, null, DocPropertyType.OBJECT);
}
@Override
@@ -0,0 +1,90 @@
package io.ebeaninternal.server.type;
import io.avaje.json.JsonReader;
import io.avaje.json.JsonWriter;
import io.ebean.core.type.ScalarType;
import io.ebean.text.TextException;
import io.ebean.text.json.EJson;
import io.ebeaninternal.json.ModifyAwareMap;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Type which maps {@code Map<Enum,Object>} to JSON.
* <p>
* The enum keys are (de)serialized via their {@link ScalarType} (honouring custom
* {@code @DbEnumValue} mappings), then delegated to {@link ScalarTypeJsonMap} for all
* storage, platform and value handling.
*/
@SuppressWarnings("rawtypes")
final class ScalarTypeJsonMapEnum<T extends Enum<T>> extends ScalarTypeJsonMap {
private final ScalarType<T> enumType;
static ScalarType<?> typeFor(boolean postgres, int dbType, ScalarType<? extends Enum<?>> enumType, boolean keepSource) {
return new ScalarTypeJsonMapEnum<>(storageFor(postgres, dbType), enumType, keepSource);
}
@SuppressWarnings("unchecked")
private ScalarTypeJsonMapEnum(JsonStorage storage, ScalarType<? extends Enum> enumType, boolean keepSource) {
super(storage, keepSource);
this.enumType = (ScalarType<T>) enumType;
}
@Override
Map readJson(String rawJson) {
return parse(rawJson);
}
@Override
public Map parse(String value) {
try {
return toEnumKeys(EJson.parseObject(value, true));
} catch (IOException e) {
throw new TextException("Failed to parse JSON [{}] as Map with enum keys", value, e);
}
}
@Override
public String formatValue(Map value) {
try {
return EJson.write(toStringKeys(value));
} catch (IOException e) {
throw new TextException(e);
}
}
@Override
public Map jsonRead(JsonReader parser) throws IOException {
return toEnumKeys(EJson.parseObject(parser, parser.currentToken()));
}
@Override
public void jsonWrite(JsonWriter writer, Map value) throws IOException {
EJson.write(toStringKeys(value), writer);
}
@SuppressWarnings("unchecked")
private Map<String, Object> toStringKeys(Map value) {
Map<String, Object> stringKeyMap = new LinkedHashMap<>();
for (Object o : value.entrySet()) {
Map.Entry e = (Map.Entry) o;
stringKeyMap.put(enumType.formatValue((T) e.getKey()), e.getValue());
}
return stringKeyMap;
}
@SuppressWarnings("unchecked")
private Map toEnumKeys(Map<String, Object> stringKeyMap) {
if (stringKeyMap == null) {
return null;
}
Map enumKeyMap = new LinkedHashMap();
for (Map.Entry<String, Object> e : stringKeyMap.entrySet()) {
enumKeyMap.put(enumType.parse(e.getKey()), e.getValue());
}
return stringKeyMap instanceof ModifyAwareMap ? new ModifyAwareMap(enumKeyMap) : enumKeyMap;
}
}
@@ -37,6 +37,14 @@ public final class TypeReflectHelper {
return getClass(getValueType(genericType));
}
/**
* Return the raw type of the map key (first type argument).
*/
public static Type getMapKeyTypeRaw(Type genericType) {
Type[] typeArgs = ((ParameterizedType) genericType).getActualTypeArguments();
return typeArgs[0];
}
/**
* Return the value type of a collection type (list, set, map values).
*/
@@ -0,0 +1,126 @@
package org.tests.json;
import io.ebean.DB;
import io.ebean.annotation.DbEnumValue;
import io.ebean.annotation.DbJson;
import io.ebean.xtest.BaseTestCase;
import org.junit.jupiter.api.Test;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Test for Map&lt;Enum, Object&gt; support in @DbJson fields.
*/
class TestEnumKeyMap extends BaseTestCase {
enum Status {
ACTIVE,
INACTIVE,
PENDING;
@DbEnumValue
public String getValue() {
return name().substring(0, 1); // A, I, P
}
}
enum Priority {
LOW,
MEDIUM,
HIGH
}
@Entity
@Table(name = "enum_map_test")
public static class EnumMapBean {
@Id
Long id;
@DbJson
Map<Status, Object> statusData;
@DbJson
Map<Priority, String> priorityLabels;
public EnumMapBean(Long id) {
this.id = id;
}
public EnumMapBean() {
}
}
@Test
void testEnumKeyMapWithCustomEnumValue() {
EnumMapBean bean = new EnumMapBean(1L);
Map<Status, Object> statusData = new LinkedHashMap<>();
statusData.put(Status.ACTIVE, "Running smoothly");
statusData.put(Status.PENDING, 42L);
statusData.put(Status.INACTIVE, Map.of("reason", "maintenance"));
bean.statusData = statusData;
DB.save(bean);
// Read it back
EnumMapBean found = DB.find(EnumMapBean.class, 1L);
assertThat(found).isNotNull();
assertThat(found.statusData).hasSize(3);
assertThat(found.statusData.get(Status.ACTIVE)).isEqualTo("Running smoothly");
assertThat(found.statusData.get(Status.PENDING)).isEqualTo(42L);
assertThat(found.statusData.get(Status.INACTIVE)).isInstanceOf(Map.class);
// Update
found.statusData.put(Status.ACTIVE, "Updated status");
found.statusData.remove(Status.INACTIVE);
DB.save(found);
EnumMapBean updated = DB.find(EnumMapBean.class, 1L);
assertNotNull(updated);
assertThat(updated.statusData).hasSize(2);
assertThat(updated.statusData.get(Status.ACTIVE)).isEqualTo("Updated status");
assertThat(updated.statusData).doesNotContainKey(Status.INACTIVE);
}
@Test
void testEnumKeyMapWithStandardEnum() {
EnumMapBean bean = new EnumMapBean(2L);
Map<Priority, String> priorityLabels = new LinkedHashMap<>();
priorityLabels.put(Priority.LOW, "Not urgent");
priorityLabels.put(Priority.MEDIUM, "Standard processing");
priorityLabels.put(Priority.HIGH, "Urgent - immediate action required");
bean.priorityLabels = priorityLabels;
DB.save(bean);
EnumMapBean found = DB.find(EnumMapBean.class, 2L);
assertThat(found).isNotNull();
assertThat(found.priorityLabels).hasSize(3);
assertThat(found.priorityLabels.get(Priority.LOW)).isEqualTo("Not urgent");
assertThat(found.priorityLabels.get(Priority.HIGH)).isEqualTo("Urgent - immediate action required");
}
@Test
void testNullAndEmptyMaps() {
EnumMapBean bean = new EnumMapBean(3L);
bean.statusData = null;
bean.priorityLabels = new LinkedHashMap<>();
DB.save(bean);
EnumMapBean found = DB.find(EnumMapBean.class, 3L);
assertThat(found).isNotNull();
assertThat(found.statusData).isNull();
assertThat(found.priorityLabels).isEmpty();
}
}