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 deserializing a Map, the default implementation used by Jackson is a HashMap, if no actual implementation is specified. This is because an interface is not a concrete class, and Jackson needs an actual Map implementation to know how to store the deserialized values. 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 coincidentally, because in the absence of a TypeReference, Jackson reads simple values as String, while complex values (objects) as LinkedHashMap. Using a TypeReference is fundamental when deserializing generic types.

    Sorting/retrieving elements with String.CASE_INSENSITIVE_ORDER Comparator

    In the 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 to work. The Comparator String.CASE_INSENSITIVE_ORDER supplied to the TreeMap's constructor is only used to establish an ordering between the elements. 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 Comparator doesn't perform a comparison when retrieving elements, but only when storing them.

    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, at no point 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 and a demo at OneCompiler.com that bind together the previous points:

    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;
        }
    }