diff --git a/matsim/pom.xml b/matsim/pom.xml index 4dc0348e536..45a3e54f201 100644 --- a/matsim/pom.xml +++ b/matsim/pom.xml @@ -257,6 +257,10 @@ com.fasterxml.jackson.core jackson-core + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + org.mockito mockito-core diff --git a/matsim/src/main/java/org/matsim/core/config/ReflectiveConfigGroup.java b/matsim/src/main/java/org/matsim/core/config/ReflectiveConfigGroup.java index 1301c6daeb1..7d6d86f0a4d 100644 --- a/matsim/src/main/java/org/matsim/core/config/ReflectiveConfigGroup.java +++ b/matsim/src/main/java/org/matsim/core/config/ReflectiveConfigGroup.java @@ -39,7 +39,9 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.EnumMap; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -48,11 +50,17 @@ import javax.annotation.Nullable; +import org.apache.commons.text.StringEscapeUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.matsim.api.core.v01.Id; import org.matsim.core.api.internal.MatsimExtensionPoint; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; @@ -105,6 +113,12 @@ public abstract class ReflectiveConfigGroup extends ConfigGroup implements MatsimExtensionPoint { private static final Logger log = LogManager.getLogger(ReflectiveConfigGroup.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + static { + OBJECT_MAPPER.registerModule(new JavaTimeModule()); + } + private final boolean storeUnknownParameters; private final Map setters; @@ -240,8 +254,10 @@ private static void checkParamFieldValidity(Field field) { Integer.TYPE, Long.TYPE, Boolean.TYPE, Character.TYPE, Byte.TYPE, Short.TYPE, LocalTime.class, LocalDate.class, LocalDateTime.class); + private static final Set> ALLOWED_MAP_PARAMETER_TYPES = ALLOWED_PARAMETER_TYPES; // currently identical + private static final String HINT = " Valid types are String, primitive types and their wrapper classes," - + " enumerations, List and Set, LocalTime, LocalDate, LocalDateTime" + + " enumerations, List and Set, Maps, LocalTime, LocalDate, LocalDateTime" + " Other types are fine as parameters, but you will need to implement conversion strategies" + " in corresponding StringGetters andStringSetters."; @@ -255,6 +271,11 @@ private static boolean checkType(Type type) { if (rawType.equals(List.class) || rawType.equals(Set.class)) { var typeArgument = pType.getActualTypeArguments()[0]; return typeArgument.equals(String.class) || (typeArgument instanceof Class && ((Class) typeArgument).isEnum()); + } else if (rawType.equals(Map.class)) { + var keyType = pType.getActualTypeArguments()[0]; + var valueType = pType.getActualTypeArguments()[1]; + return (ALLOWED_MAP_PARAMETER_TYPES.contains(keyType) || (keyType instanceof Class && ((Class) keyType).isEnum())) && + (ALLOWED_MAP_PARAMETER_TYPES.contains(valueType) || (valueType instanceof Class && ((Class) valueType).isEnum())); } if (rawType.equals(Class.class)) @@ -406,6 +427,22 @@ private Object fromString(String value, Class type, @Nullable Field paramFiel return stream.map(s -> stringToEnumValue(s, enumConstants)).toList(); } return stream.toList(); + } else if (type.equals(Map.class) && !type.equals(EnumMap.class)) { + if (value.isBlank()) { + return Collections.emptyMap(); + } else if (paramField != null && paramField.getGenericType() instanceof ParameterizedType pType) { + Class keyClass = TypeFactory.rawClass(pType.getActualTypeArguments()[0]); + Class valueClass = TypeFactory.rawClass(pType.getActualTypeArguments()[1]); + Class mapClass = keyClass.isEnum() ? EnumMap.class : LinkedHashMap.class; + JavaType mapType = OBJECT_MAPPER.getTypeFactory().constructMapType(mapClass, keyClass, + valueClass); + try { + return OBJECT_MAPPER.readValue(value, mapType); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + throw new RuntimeException("Unsupported map field"); } else if (type.equals(Class.class)) { try { return ClassLoader.getSystemClassLoader().loadClass(value); @@ -484,6 +521,17 @@ private String toString(Object result) { Preconditions.checkArgument(collection.stream().noneMatch(String::isBlank), "Collection %s contains blank elements. Only non-blank elements are supported.", collection); return String.join(", ", collection); + } else if (result instanceof Map mapResult) { + Preconditions.checkArgument(mapResult.keySet().stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .noneMatch(String::isBlank), + "Map %s contains blank string keys. Only non-blank string keys are supported.", mapResult); + try { + return new StringEscapeUtils().escapeXml11(OBJECT_MAPPER.writeValueAsString(result)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } else { return result + ""; } diff --git a/matsim/src/test/java/org/matsim/core/config/ReflectiveConfigGroupTest.java b/matsim/src/test/java/org/matsim/core/config/ReflectiveConfigGroupTest.java index a662f53d5ad..b19c1552100 100644 --- a/matsim/src/test/java/org/matsim/core/config/ReflectiveConfigGroupTest.java +++ b/matsim/src/test/java/org/matsim/core/config/ReflectiveConfigGroupTest.java @@ -26,8 +26,11 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -69,6 +72,11 @@ public void testDumpAndRead() { dumpedModule.localDateTimeField = LocalDateTime.of(2022, 12, 31, 23, 59, 59); dumpedModule.enumListField = List.of(MyEnum.VALUE1, MyEnum.VALUE2); dumpedModule.enumSetField = Set.of(MyEnum.VALUE2); + dumpedModule.integerMapField = Map.of("a\"b", 1, "b", 2); + dumpedModule.localDateMapField = Map.of('d', LocalDate.of(2023, 10, 9)); + dumpedModule.booleanMapField = Map.of(0.1, true); + dumpedModule.enumMapField = new EnumMap<>(MyEnum.class); + dumpedModule.enumMapField.put(MyEnum.VALUE1, "abc"); dumpedModule.setField = ImmutableSet.of("a", "b", "c"); dumpedModule.listField = List.of("1", "2", "3"); assertEqualAfterDumpAndRead(dumpedModule); @@ -87,6 +95,9 @@ public void testDumpAndReadEmptyCollections() { dumpedModule.setField = ImmutableSet.of(); dumpedModule.enumListField = List.of(); dumpedModule.enumSetField = ImmutableSet.of(); + dumpedModule.integerMapField = Collections.emptyMap(); + dumpedModule.localDateMapField = Collections.emptyMap(); + dumpedModule.booleanMapField = Collections.emptyMap(); assertEqualAfterDumpAndRead(dumpedModule); } @@ -97,14 +108,23 @@ public void testDumpAndReadCollectionsWithExactlyOneEmptyString() { //fail on list dumpedModule.listField = List.of(""); dumpedModule.setField = null; + dumpedModule.integerMapField = null; assertThatThrownBy(() -> assertEqualAfterDumpAndRead(dumpedModule)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Collection [] contains blank elements. Only non-blank elements are supported."); //fail on set dumpedModule.listField = null; dumpedModule.setField = ImmutableSet.of(""); + dumpedModule.integerMapField = null; assertThatThrownBy(() -> assertEqualAfterDumpAndRead(dumpedModule)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Collection [] contains blank elements. Only non-blank elements are supported."); + + // fail on map + dumpedModule.listField = null; + dumpedModule.setField = null; + dumpedModule.integerMapField = Map.of("", 2); + assertThatThrownBy(() -> assertEqualAfterDumpAndRead(dumpedModule)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Map {=2} contains blank string keys. Only non-blank string keys are supported."); } @Test @@ -114,14 +134,25 @@ public void testDumpAndReadCollectionsIncludingEmptyString() { //fail on list dumpedModule.listField = List.of("non-empty", ""); dumpedModule.setField = ImmutableSet.of("non-empty"); + dumpedModule.integerMapField = Map.of("a", 1, "b", 2); assertThatThrownBy(() -> assertEqualAfterDumpAndRead(dumpedModule)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Collection [non-empty, ] contains blank elements. Only non-blank elements are supported."); //fail on set dumpedModule.listField = List.of("non-empty"); dumpedModule.setField = ImmutableSet.of("non-empty", ""); + dumpedModule.integerMapField = Map.of("a", 1, "b", 2); assertThatThrownBy(() -> assertEqualAfterDumpAndRead(dumpedModule)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Collection [non-empty, ] contains blank elements. Only non-blank elements are supported."); + + // fail on map + dumpedModule.listField = List.of("non-empty"); + dumpedModule.setField = ImmutableSet.of("non-empty"); + dumpedModule.integerMapField = new LinkedHashMap<>(); + dumpedModule.integerMapField.put("a", 1); + dumpedModule.integerMapField.put("", 2); + assertThatThrownBy(() -> assertEqualAfterDumpAndRead(dumpedModule)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Map {a=1, =2} contains blank string keys. Only non-blank string keys are supported."); } private void assertEqualAfterDumpAndRead(MyModule dumpedModule) { @@ -167,6 +198,10 @@ public void testComments() { expectedComments.put("enumListField", "list of enum"); expectedComments.put("enumSetField", "set of enum"); expectedComments.put("setField", "set"); + expectedComments.put("integerMapField", "map of Integer"); + expectedComments.put("localDateMapField", "map of LocalDate"); + expectedComments.put("booleanMapField", "map of Boolean"); + expectedComments.put("enumMapField", "map of Enum"); assertThat(new MyModule().getComments()).isEqualTo(expectedComments); } @@ -472,6 +507,22 @@ private static class MyModule extends ReflectiveConfigGroup { @Parameter private Set enumSetField; + @Comment("map of Integer") + @Parameter + private Map integerMapField; + + @Comment("map of LocalDate") + @Parameter + private Map localDateMapField; + + @Comment("map of Boolean") + @Parameter + private Map booleanMapField; + + @Comment("map of Enum") + @Parameter + private Map enumMapField; + // Object fields: // Id: string representation is toString private Id idField;