javaserializationjacksondeserializationtreemap

Serialize/Deserialize case insensitive map using jackson ObjectMapper


I am trying to use Jackson's ObjectMapper class to serialize an object that looks like this:

TreeMap<String, String> mappings = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);

but when the object is serialized, it ends up looking like this:

{"mappings": {"key": "value"}}

When deserializing, it loses the case insensitive property of the map. Does anyone know how to resolve this, or possibly a type of case insensitive map class which I can use to serialize and deserialize? Is there a Jackson mapper property that I can use to fix this issue?

Here is some sample code:

    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.ObjectMapper;
    
    import java.util.TreeMap;
    
    public class Main {
        public static void main(String[] args) throws JsonProcessingException {
            TreeMap<String, String> mappings = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
            mappings.put("Test3", "3");
            mappings.put("test1", "1");
            mappings.put("Test2", "2");
    
            ObjectMapper objectMapper = new ObjectMapper();
            String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(mappings);
            System.out.println(json);
            
            TreeMap<String, String> deserMappings = objectMapper.readValue(json, TreeMap.class);
            
            System.out.println("Deserialized map case insensitive test: " + deserMappings.get("test3"));
        }
    }

And sample output:

    {
      "test1" : "1",
      "Test2" : "2",
      "Test3" : "3"
    }
    Deserialized map case insensitive test: null

Solution

  • After reading the comments and seeing how generic objects are deserialized, I think I can provide an answer that better addresses your problems:


    Deserializing using the Map interface

    When you don't use a specific implementation but an interface when deserializing Maps, the default implementation used by Jackson is a HashMap. This is because an interface is not a concrete class, while Jackson needs an actual Map implementation to deserialize the Json values somewhere. This is what was happening to you, judging from the initial problem described in the comments:

    I originally had this deserializing to a field which was Map<String, String> but when I checked if it was a TreeMap using mappings instanceof TreeMap it returned false. It returned true when I did mappings instanceof HashMap.

    Deserializing generic objects

    When deserializing generic objects, you should use an instance of the abstract class TypeReference that subclasses the generic type you're trying to read. In your case: new TypeReference<TreeMap<String, String>>() {}. Like so, the argument type of the TypeReference (TreeMap<String, String>) is used by Jackson to reconstruct the json values ("test1", "1", "Test2", "2", ...) into the argument types (String and String) of the generic type subclassed by the TypeReference. In your snippet, the code is still working only by coincidence, because in the absence of a TypeReference, Jackson reads simple values as String, while complex values (objects) as LinkedHashMap. Using a TypeReference for generic types is very important in general, but even more when deserializing complex objects (see MyBean example below). You should always use a TypeReference for generic types.

    Sorting/retrieving elements with String.CASE_INSENSITIVE_ORDER Comparator

    In your code deserMappings.get("test3"), I'm assuming but I'm not sure, that there might be a misunderstanding in the way you expect the Comparator parameter to work. The Comparator String.CASE_INSENSITIVE_ORDER supplied to the constructor is only used to establish an ordering between the elements of the TreeMap, it is not used to treat those elements in a case insensitive way. Therefore, when you try to retrieve an element with the key "test3", nothing is returned because there is no value identified by "test3", but there is by "Test3". The two strings "test3" and "Test3" are still different strings, because the given Comparator is only used to define the order of how elements should be stored within the TreeMap.

    Maintaining String.CASE_INSENSITIVE_ORDER Comparator

    This is related to the comment:

    My main issue is the issue with deserializing not maintaining the case insensitive property

    The reason why the TreeMap read with the ObjectMapper is not maintaining the original case insensitive order is because you're creating a whole new instance with a default order, which, in the case of String keys, is a case sensitive ordering. In your code, there is no point where you've defined a custom comparator for the second TreeMap instance (deserMappings).

    TreeMap<String, String> deserMappings = objectMapper.readValue(json, TreeMap.class);
    

    Recap Example

    Here, I'm sharing an example that binds together the previous explanation with some code. Here is also a live version on OneCompiler.com: https://onecompiler.com/java/426cmfucg

    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.core.type.TypeReference;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    
    import java.util.Map;
    import java.util.TreeMap;
    
    public class Main {
        public static void main(String[] args) throws JsonProcessingException {
            //Serializing a TreeMap with String keys and String values
            TreeMap<String, String> mappings = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
            mappings.put("Test3", "3");
            mappings.put("test1", "1");
            mappings.put("Test2", "2");
    
            ObjectMapper objectMapper = new ObjectMapper();
            String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(mappings);
            System.out.println("mappings: \n" + json);
    
            // Deserializing to a generic type without TypeReference (wrong approach).
            //
            // First of all, a small side note on deserializing with an interface. When you don't use a specific implementation
            // but an interface when deserializing Maps, the default implementation used by Jackson is a HashMap. This is because
            // an interface is not a concrete class, while Jackson needs an actual Map implementation to deserialize the Json
            // values somewhere. This is what was happening to you, judging from the initial problem described in the comments:
            //
            // > "I originally had this deserializing to a field which was Map<String, String> but when I checked if it was a
            // > TreeMap using mappings instanceof TreeMap it returned false. It returned true when I did mappings instanceof HashMap."
            //
            // Now, back to the TypeReference approach. In this case, even without using a TypeReference instance, the
            // deserialization happens successfully because json values are read as String, which is coincidentally the same
            // type of your Map's values. However, in general, when deserializing generic types you should subclass the actual
            // type representing your data. This is because the argument type tells Jackson how to unmarshall those values.
            Map<String, String> mappingsAsHashMap = objectMapper.readValue(json, Map.class);
            System.out.println(mappingsAsHashMap instanceof TreeMap<String, String>);   //Prints false because, by default, it was deserialized with a HashMap
    
            Map<String, String> mappinggsAsTreeMap = objectMapper.readValue(json, TreeMap.class);
            System.out.println(mappinggsAsTreeMap instanceof TreeMap<String, String>);  //Prints true because you specified the actual implementation
    
            //Works because the values have been read as a String
            Map<String, String> mappingsWithStringValues = objectMapper.readValue(json, TreeMap.class);
            String s = mappingsWithStringValues.get("test1");
            System.out.println("length of s :" + s.length());
    
            //Fails because tries to treat the values as Integer while the actual instances are String
            Map<String, Integer> mappingsWithIntValues = objectMapper.readValue(json, TreeMap.class);
            try {
                Integer i = mappingsWithIntValues.get("test1");
                System.out.println(i.intValue());
            } catch (ClassCastException ex){
                System.out.println("Attempting to read a String as an Integer");
            }
    
    
            //Serializing a TreeMap with String keys and a custom Bean
            TreeMap<String, MyBean> mappingsBean = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
            mappingsBean.put("Test3", new MyBean(3, "MyBean3"));
            mappingsBean.put("test1", new MyBean(1, "MyBean1"));
            mappingsBean.put("Test2", new MyBean(2, "MyBean2"));
    
            String jsonBean = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(mappingsBean);
            System.out.println("\n" + "mappingsBean:\n" + jsonBean);
    
            // Deserializing the mappingsBean json without a TypeReference (wrong approach).
            //
            // Like so, Jackson doesn't know to which data type those inner values ({"id" : 2,"name" : "MyBean2"}) correspond to,
            // therefore that sequence of key-value pairs is read as a LinkedHashMap, and even worse, it is added as such within
            // your Map despite being a TreeMap<String, MyBean>! In fact, if you attempt to read or perform any operation on
            // its values, you will raise a ClassCastException as the type expected for your values is MyBean and not LinkedHashMap.
            mappingsBean = objectMapper.readValue(jsonBean, TreeMap.class);
            try {
                MyBean myBean = mappingsBean.get("test1");
            } catch (ClassCastException ex) {
                System.out.println("Attempting to read a LinkedHashMap as a MyBean");
            }
    
            try {
                System.out.println(mappingsBean.get("test1").getId());
            } catch (ClassCastException ex) {
                System.out.println("Attempting to perform a MyBean's operation on a LinkedHashMap");
            }
    
            // Deserializing the mappingsBean json with a TypeReference (right approach).
            //
            // With this approach, Jackson knows via the argument type of the TypeReference, that those inner values
            // ({"id" : 2,"name" : "MyBean2"}) correspond to a MyBean that needs to be reconstructed.
            mappingsBean = objectMapper.readValue(jsonBean, new TypeReference<TreeMap<String, MyBean>>() {
            });
            System.out.println("\n" + mappingsBean);
    
    
            //Proper deserialization of your original Map while maintaining the case insensitive order.
            //
            // If you attempt to simply read the initial json with a TypeReference that subclasses a TreeMap<String, String>
            // you'll find out that it won't maintain your custom ordering, since the default ordering is case-sensitive.
            // This is noticeable when looking at the print of mappingsBean, it follows the default case-sensitive ordering.
            // Therefore, if you want to read your json as a TreeMap and maintain your custom case insensitive ordering, you
            // need to define a temporary TreeMap where to read the json, and a result TreeMap initialized with the custom
            // comparator and all the key-value pairs from the temporary TreeMap.
    
            //Wrong ordering mappings
            System.out.println("\nWrong Ordering mappings:\n" + objectMapper.readValue(json, new TypeReference<TreeMap<String, String>>() {
            }));
    
            //Right ordering mappings
            TreeMap<String, String> mappingsTemp = objectMapper.readValue(json, new TypeReference<TreeMap<String, String>>() {
            });
            TreeMap<String, String> mappingsRes = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
            mappingsRes.putAll(mappingsTemp);
            System.out.println("\nRight Ordering mappings:\n" + mappingsRes);
        }
    
        @NoArgsConstructor
        @AllArgsConstructor
        @Data
        static class MyBean {
            private int id;
            private String name;
        }
    }