Serialize/Deserialize case insensitive map using jackson ObjectMapper

71 Views Asked by At

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
2

There are 2 best solutions below

0
dani-vta On BEST ANSWER

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 (from the comment section).
  • Deserializing generic objects (from post).
  • Sorting/retrieving elements with String.CASE_INSENSITIVE_ORDER Comparator (from post).
  • Maintaining String.CASE_INSENSITIVE_ORDER Comparator (from comment section).

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;
    }
}
0
Jimmy Johnson On

I think you are misinterpreting the formatting parameter. It's describing a situation where you want to ignore case, which is what it's doing, ignoring the fact that you have key values starting with 'T' and 't'. It's treating them all as if they were the same case and putting them in order based on the rest of the string. The object call also allows you to define your own sorting comparator. You could just use this if you wanted to do something special.

https://docs.oracle.com/javase/8/docs/api/java/util/TreeMap.html#TreeMap-java.util.Comparator-