diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/DefaultTypeManager.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/DefaultTypeManager.java index 4e46e0b64b..2f6a33d75f 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/type/DefaultTypeManager.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/DefaultTypeManager.java @@ -319,8 +319,24 @@ public ScalarType dbJsonType(DeployBeanProperty prop, int dbType, int dbLengt if (type.equals(Set.class) && isValueTypeSimple(genericType)) { return ScalarTypeJsonSet.typeFor(postgres, dbType, docType(genericType), prop.isNullable(), keepSource(prop)); } - if (type.equals(Map.class) && isMapValueTypeObject(genericType)) { - return ScalarTypeJsonMap.typeFor(postgres, dbType, keepSource(prop)); + if (type.equals(Map.class)) { + Type keyType = TypeReflectHelper.getMapKeyTypeRaw(genericType); + if (isMapValueTypeObject(genericType)) { + if (isEnumType(keyType)) { + return enumJsonMapType( + postgres, + dbType, + keyType, + keepSource(prop) + ); + } else { + return ScalarTypeJsonMap.typeFor( + postgres, + dbType, + keepSource(prop) + ); + } + } } if (objectMapperPresent && prop.getMutationDetection() == MutationDetection.DEFAULT) { ScalarTypeSet typeSet = typeSets.get(type); @@ -331,6 +347,24 @@ public ScalarType dbJsonType(DeployBeanProperty prop, int dbType, int dbLengt return createJsonObjectMapperType(prop, dbType, DocPropertyType.OBJECT); } + @SuppressWarnings("unchecked") + private > ScalarTypeJsonMapEnum enumJsonMapType( + boolean postgres, + int dbType, + Type keyType, + boolean keepSource + ) { + Class enumClass = (Class) asEnumClass(keyType); + ScalarType enumScalarType = (ScalarType) enumType(enumClass, null); + + return ScalarTypeJsonMapEnum.typeFor( + postgres, + dbType, + enumScalarType, + keepSource + ); + } + private boolean keepSource(DeployBeanProperty prop) { if (prop.getMutationDetection() == MutationDetection.DEFAULT) { prop.setMutationDetection(jsonManager.mutationDetection()); @@ -560,6 +594,7 @@ private ScalarTypeEnum enumTypePerExtensions(Class> enumTyp */ private ScalarTypeEnum enumTypeDbValue(Class> enumType, Method method, boolean integerType, int length, boolean withConstraint) { Map nameValueMap = new LinkedHashMap<>(); + method.setAccessible(true); for (Enum enumConstant : enumType.getEnumConstants()) { try { Object value = method.invoke(enumConstant); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonMapEnum.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonMapEnum.java new file mode 100644 index 0000000000..9d5e55f689 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonMapEnum.java @@ -0,0 +1,308 @@ +package io.ebeaninternal.server.type; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import io.ebean.config.dbplatform.DbPlatformType; +import io.ebean.core.type.*; +import io.ebean.text.TextException; +import io.ebean.text.json.EJson; +import io.ebean.util.IOUtils; +import io.ebeaninternal.json.ModifyAwareMap; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.sql.Types; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Type which maps Map to various DB types (Clob, Varchar, Blob) in JSON format. + * The enum keys are serialized using their ScalarType (which may use custom enum values). + */ +@SuppressWarnings("rawtypes") +abstract class ScalarTypeJsonMapEnum> extends ScalarTypeBase { + + final boolean keepSource; + private final ScalarType enumScalarType; + + ScalarTypeJsonMapEnum(int jdbcType, ScalarType enumScalarType, boolean keepSource) { + super(Map.class, false, jdbcType); + this.enumScalarType = enumScalarType; + this.keepSource = keepSource; + } + + /** + * Return the ScalarType for the requested dbType and postgres. + */ + static > ScalarTypeJsonMapEnum typeFor(boolean postgres, int dbType, ScalarType enumScalarType, boolean keepSource) { + switch (dbType) { + case Types.VARCHAR: + return new ScalarTypeJsonMapEnum.Varchar<>(enumScalarType, keepSource); + case Types.BLOB: + return new ScalarTypeJsonMapEnum.Blob<>(enumScalarType, keepSource); + case Types.CLOB: + return new ScalarTypeJsonMapEnum.Clob<>(enumScalarType, keepSource); + case DbPlatformType.JSONB: + return postgres ? new ScalarTypeJsonMapEnumPostgres.JSONB<>(enumScalarType, keepSource) : new ScalarTypeJsonMapEnum.Clob<>(enumScalarType, keepSource); + case DbPlatformType.JSON: + return postgres ? new ScalarTypeJsonMapEnumPostgres.JSON<>(enumScalarType, keepSource) : new ScalarTypeJsonMapEnum.Clob<>(enumScalarType, keepSource); + default: + throw new IllegalStateException("Unknown dbType " + dbType); + } + } + + /** + * Map is a mutable type. Use the isDirty() method to check for dirty state. + */ + @Override + public final boolean mutable() { + return true; + } + + /** + * Return true if the value should be considered dirty (and included in an update). + */ + @Override + public boolean isDirty(Object value) { + return TypeJsonManager.checkIsDirty(value); + } + + @Override + public final boolean jsonMapper() { + return keepSource; + } + + @Override + public Map read(DataReader reader) throws SQLException { + String rawJson = readJson(reader); + if (keepSource) { + reader.pushJson(rawJson); + } + if (rawJson == null) { + return null; + } + return parse(rawJson); + } + + protected String readJson(DataReader reader) throws SQLException { + return reader.getString(); + } + + @Override + public final void bind(DataBinder binder, Map value) throws SQLException { + String rawJson = keepSource ? binder.popJson() : null; + if (rawJson == null && value != null) { + rawJson = formatValue(value); + } + if (value == null) { + bindNull(binder); + } else { + bindJson(binder, rawJson); + } + } + + protected void bindNull(DataBinder binder) throws SQLException { + binder.setNull(Types.VARCHAR); + } + + protected void bindJson(DataBinder binder, String rawJson) throws SQLException { + binder.setString(rawJson); + } + + @Override + public final Object toJdbcType(Object value) { + return value; + } + + @Override + public final Map toBeanType(Object value) { + return (Map) value; + } + + /** + * Convert Map to JSON string. + * Enum keys are serialized using the enumScalarType's formatValue method. + */ + @Override + @SuppressWarnings("unchecked") + public final String formatValue(Map v) { + try { + // Convert enum keys to their string representation + Map stringKeyMap = new LinkedHashMap<>(); + for (Object entry : v.entrySet()) { + Map.Entry e = (Map.Entry) entry; + T mapKey = (T) e.getKey(); + String key = enumScalarType.formatValue(mapKey); + stringKeyMap.put(key, e.getValue()); + } + return EJson.write(stringKeyMap); + } catch (IOException e) { + throw new TextException(e); + } + } + + /** + * Parse JSON string to Map. + * String keys are parsed back to enum values using the enumScalarType's parse method. + */ + public final Map parse(String value) { + try { + Map stringKeyMap = EJson.parseObject(value, true); + return convertToEnumKeyMap(stringKeyMap); + } catch (IOException e) { + throw new TextException("Failed to parse JSON [{}] as Map with enum keys", value, e); + } + } + + public final Map parse(Reader reader) { + try { + Map stringKeyMap = EJson.parseObject(reader, true); + return convertToEnumKeyMap(stringKeyMap); + } catch (IOException e) { + throw new TextException(e); + } + } + + /** + * Convert a Map to Map by parsing string keys to enum values. + */ + @SuppressWarnings("unchecked") + private Map convertToEnumKeyMap(Map stringKeyMap) { + if (stringKeyMap == null) { + return null; + } + + boolean modifyAware = stringKeyMap instanceof ModifyAwareMap; + + Map newMap = new HashMap<>(stringKeyMap); + for (Map.Entry entry : stringKeyMap.entrySet()) { + Object enumKey = enumScalarType.parse(entry.getKey()); + newMap.remove(entry.getKey()); + newMap.put(enumKey, entry.getValue()); + } + return modifyAware ? new ModifyAwareMap(newMap) : newMap; + } + + @Override + public final Map readData(DataInput dataInput) throws IOException { + if (!dataInput.readBoolean()) { + return null; + } else { + return parse(dataInput.readUTF()); + } + } + + @Override + public final void writeData(DataOutput dataOutput, Map map) throws IOException { + if (map == null) { + dataOutput.writeBoolean(false); + } else { + ScalarHelp.writeUTF(dataOutput, format(map)); + } + } + + @Override + @SuppressWarnings("unchecked") + public final void jsonWrite(JsonGenerator writer, Map value) throws IOException { + if (value == null) { + writer.writeNull(); + } else { + writer.writeStartObject(); + for (Object entry : value.entrySet()) { + Map.Entry e = (Map.Entry) entry; + T mapKey = (T) e.getKey(); + String key = enumScalarType.formatValue(mapKey); + writer.writeFieldName(key); + writer.writeObject(e.getValue()); + } + writer.writeEndObject(); + } + } + + @Override + public final Map jsonRead(JsonParser parser) throws IOException { + JsonToken token = parser.getCurrentToken(); + if (token == JsonToken.VALUE_NULL) { + return null; + } + Map stringKeyMap = EJson.parseObject(parser, token); + return convertToEnumKeyMap(stringKeyMap); + } + + @Override + public final DocPropertyType docType() { + return DocPropertyType.OBJECT; + } + + private static final class Clob> extends ScalarTypeJsonMapEnum { + Clob(ScalarType enumScalarType, boolean keepSource) { + super(Types.CLOB, enumScalarType, keepSource); + } + + @Override + protected String readJson(DataReader reader) throws SQLException { + return reader.getStringFromStream(); + } + } + + private static final class Varchar> extends ScalarTypeJsonMapEnum { + Varchar(ScalarType enumScalarType, boolean keepSource) { + super(Types.VARCHAR, enumScalarType, keepSource); + } + } + + private static final class Blob> extends ScalarTypeJsonMapEnum { + Blob(ScalarType enumScalarType, boolean keepSource) { + super(Types.BLOB, enumScalarType, keepSource); + } + + private static void transferTo(Reader reader, Writer out) throws IOException { + char[] buffer = new char[2048]; + int nRead; + while ((nRead = reader.read(buffer, 0, 2048)) >= 0) { + out.write(buffer, 0, nRead); + } + } + + @Override + public Map read(DataReader reader) throws SQLException { + InputStream is = reader.getBinaryStream(); + if (is == null) { + if (keepSource) { + reader.pushJson(null); + } + return null; + } + try { + if (keepSource) { + StringWriter jsonBuffer = new StringWriter(); + try (Reader streamReader = IOUtils.newReader(is)) { + transferTo(streamReader, jsonBuffer); + } + String rawJson = jsonBuffer.toString(); + reader.pushJson(rawJson); + return parse(rawJson); + } else { + try (Reader streamReader = IOUtils.newReader(is)) { + return parse(streamReader); + } + } + } catch (IOException e) { + throw new SQLException("Error reading Blob stream from DB", e); + } + } + + @Override + protected void bindNull(DataBinder binder) throws SQLException { + binder.setNull(Types.BLOB); + } + + @Override + protected void bindJson(DataBinder binder, String rawJson) throws SQLException { + binder.setBytes(rawJson.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonMapEnumPostgres.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonMapEnumPostgres.java new file mode 100644 index 0000000000..f3cc546062 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonMapEnumPostgres.java @@ -0,0 +1,51 @@ +package io.ebeaninternal.server.type; + +import io.ebean.config.dbplatform.DbPlatformType; +import io.ebean.core.type.DataBinder; +import io.ebean.core.type.PostgresHelper; +import io.ebean.core.type.ScalarType; + +import java.sql.SQLException; + +/** + * Support for the Postgres DB types JSON and JSONB for Map. + */ +abstract class ScalarTypeJsonMapEnumPostgres> extends ScalarTypeJsonMapEnum { + + private final String postgresType; + + ScalarTypeJsonMapEnumPostgres(int jdbcType, String postgresType, ScalarType enumScalarType, boolean keepSource) { + super(jdbcType, enumScalarType, keepSource); + this.postgresType = postgresType; + } + + @Override + protected final void bindNull(DataBinder binder) throws SQLException { + binder.setObject(PostgresHelper.asObject(postgresType, null)); + } + + @Override + protected final void bindJson(DataBinder binder, String rawJson) throws SQLException { + binder.setObject(PostgresHelper.asObject(postgresType, rawJson)); + } + + /** + * ScalarType mapping java Map type to Postgres JSON database type. + */ + static final class JSON> extends ScalarTypeJsonMapEnumPostgres { + + JSON(ScalarType enumScalarType, boolean keepSource) { + super(DbPlatformType.JSON, PostgresHelper.JSON_TYPE, enumScalarType, keepSource); + } + } + + /** + * ScalarType mapping java Map type to Postgres JSONB database type. + */ + static final class JSONB> extends ScalarTypeJsonMapEnumPostgres { + + JSONB(ScalarType enumScalarType, boolean keepSource) { + super(DbPlatformType.JSONB, PostgresHelper.JSONB_TYPE, enumScalarType, keepSource); + } + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeReflectHelper.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeReflectHelper.java index 761ea90fdd..6198de8b6d 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeReflectHelper.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeReflectHelper.java @@ -37,6 +37,11 @@ public static Class getMapKeyType(Type genericType) { return getClass(getValueType(genericType)); } + 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). */ diff --git a/ebean-test/src/test/java/org/tests/json/TestEnumKeyMap.java b/ebean-test/src/test/java/org/tests/json/TestEnumKeyMap.java new file mode 100644 index 0000000000..a7f825b808 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/json/TestEnumKeyMap.java @@ -0,0 +1,127 @@ +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 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 statusData; + + @DbJson + Map priorityLabels; + + public EnumMapBean(Long id) { + this.id = id; + } + + public EnumMapBean() { + } + } + + @Test + void testEnumKeyMapWithCustomEnumValue() { + EnumMapBean bean = new EnumMapBean(1L); + + Map 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); + System.out.println(found.statusData); + 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 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(); + } +}