mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-09-25 11:32:07 +00:00
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:
@@ -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<Enum, Object> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user