Search code examples
javaspringmongodbjacksonspring-data-mongodb

How to read a com.fasterxml.jackson.databind.node.TextNode from a Mongo DB and convert to a Map <String, Object>?


We are using SpringDataMongoDB in a Spring-boot app to manage our data.

Our previous model was this:

public class Response implements Serializable {
    //...
    private JsonNode errorBody; //<-- Dynamic
    //...
}

JsonNode FQDN is com.fasterxml.jackson.databind.JsonNode

Which saved documents like so in the DB:

"response": {
  ...
        "errorBody": {
          "_children": {
            "code": {
              "_value": "Error-code-value",
              "_class": "com.fasterxml.jackson.databind.node.TextNode"
            },
            "message": {
              "_value": "Error message value",
              "_class": "com.fasterxml.jackson.databind.node.TextNode"
            },
            "description": {
              "_value": "Error description value",
              "_class": "com.fasterxml.jackson.databind.node.TextNode"
            }
          },
          "_nodeFactory": {
            "_cfgBigDecimalExact": false
          },
          "_class": "com.fasterxml.jackson.databind.node.ObjectNode"
     },
  ...
 }

We've saved hundreds of documents like this on the production database without ever the need to read them programmatically as they are just kind of logs.

As we noticed that this output could be difficult to read in the future, we've decided to change the model to this:

public class Response implements Serializable {
    //...
    private Map<String,Object> errorBody;
    //...
}

The data are now saved like so:

"response": {
  ...
        "errorBody": {
          "code": "Error code value",
          "message": "Error message value",
          "description": "Error description value",
          ...
        },
  ...
 }

Which, as you may have noticed is pretty much more simple.

When reading the data, ex: repository.findAll()

The new format is read without any issue.

But we face these issues with the old format:

org.springframework.data.mapping.MappingException: No property v found on entity class com.fasterxml.jackson.databind.node.TextNode to bind constructor parameter to!

Or

org.springframework.data.mapping.model.MappingInstantiationException: Failed to instantiate com.fasterxml.jackson.databind.node.ObjectNode using constructor NO_CONSTRUCTOR with arguments

Of course the TextNode class has a constructor with v as param but the property name is _value and ObjectNode has no default constructor: We simply can't change that.

We've created custom converters that we've added to our configurations.

public class ObjectNodeWriteConverter implements Converter<ObjectNode, DBObject> {    
    @Override
    public DBObject convert(ObjectNode source) {
        return BasicDBObject.parse(source.toString());
    }
}
public class ObjectNodeReadConverter implements Converter<DBObject, ObjectNode> {
    @Override
    public ObjectNode convert(DBObject source) {
        try {
            return new ObjectMapper().readValue(source.toString(), ObjectNode.class);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}

We did the same for TextNode

But we still got the errors.

The converters are read as we have a ZonedDateTimeConverter that is doing his job.

We can not just wipe out or ignore the old data as we need to read them too in order to study them.

How can we set up a custom reader that will not fail reading the old format ?


Solution

  • Michal Ziober's answer did not completely solve the problem as we need to tell SpringData MongoDb that we want it to use the custom deserializer (Annotating the model does not work with Spring data mongodb):

    1. Define the custom deserializer
    public class ErrorMapJsonDeserializer extends JsonDeserializer<Map<String, Object>> {
    
        @Override
        public Map<String, Object> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
            TreeNode root = p.readValueAsTree();
            if (!root.isObject()) {
                // ignore everything except JSON Object
                return Collections.emptyMap();
            }
            ObjectNode objectNode = (ObjectNode) root;
            if (isOldFormat(objectNode)) {
                return deserialize(objectNode);
            }
            return toMap(objectNode);
        }
    
        protected boolean isOldFormat(ObjectNode objectNode) {
            final List<String> oldFormatKeys = Arrays.asList("_children", "_nodeFactory", "_class");
            final Iterator<String> iterator = objectNode.fieldNames();
            while (iterator.hasNext()) {
                String field = iterator.next();
                return oldFormatKeys.contains(field);
            }
            return false;
        }
    
        protected Map<String, Object> deserialize(ObjectNode root) {
            JsonNode children = root.get("_children");
            if (children.isArray()) {
                children = children.get(0);
                children = children.get("_children");
            }
            return extractValues(children);
        }
    
        private Map<String, Object> extractValues(JsonNode children) {
            Map<String, Object> result = new LinkedHashMap<>();
            children.fields().forEachRemaining(entry -> {
                String key = entry.getKey();
                if (!key.equals("_class"))
                    result.put(key, entry.getValue().get("_value").toString());
            });
            return result;
        }
    
    
        private Map<String, Object> toMap(ObjectNode objectNode) {
            Map<String, Object> result = new LinkedHashMap<>();
            objectNode.fields().forEachRemaining(entry -> {
                result.put(entry.getKey(), entry.getValue().toString());
            });
            return result;
        }
    }
    
    1. Create a Custom mongo converter and pass it the custom deserializer.

    Actually we do not pass the serializer directly but by means of an ObjectMapper configured with that Custom deserializer

    public class CustomMappingMongoConverter extends MappingMongoConverter {
    
        //The configured objectMapper that will be passed during instantiation
        private ObjectMapper objectMapper; 
    
        public CustomMappingMongoConverter(DbRefResolver dbRefResolver, MappingContext<? extends MongoPersistentEntity<?>, MongoPersistentProperty> mappingContext, ObjectMapper objectMapper) {
            super(dbRefResolver, mappingContext);
            this.objectMapper = objectMapper;
        }
    
        @Override
        public <S> S read(Class<S> clazz, Bson dbObject) {
            try {
                return objectMapper.readValue(dbObject.toString(), clazz);
            } catch (IOException e) {
                throw new RuntimeException(dbObject.toString(), e);
            }
        }
    
    
        //in case you want to serialize with your custom objectMapper as well
        @Override
        public void write(Object obj, Bson dbo) {
            String string = null;
            try {
                string = objectMapper.writeValueAsString(obj);
            } catch (JsonProcessingException e) {
                throw new RuntimeException(string, e);
            }
            ((DBObject) dbo).putAll((DBObject) BasicDBObject.parse(string));
        }
    
    }
    
    1. Create and configure the object mapper then instantiate the custom MongoMappingConverter and add it to Mongo configurations
    public class MongoConfiguration extends AbstractMongoClientConfiguration {
    
       
        //... other configuration method beans
       
        @Bean
        @Override
        public MappingMongoConverter mappingMongoConverter() throws Exception {
            DbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoDbFactory());
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
            objectMapper.registerModule(new SimpleModule() {
                {
                    addDeserializer(Map.class, new ErrorMapJsonDeserializer());
                }
            });
            return new CustomMappingMongoConverter(dbRefResolver, mongoMappingContext(), objectMapper);
        }
    }