/*
* Copyright 2013 eBuddy B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ebuddy.cassandra.structure;
import static java.util.AbstractMap.SimpleEntry;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.ebuddy.cassandra.Path;
/**
* Support for composing paths back to complex objects.
* Only the basic JSON structures are supported, i.e. Maps, Lists, Strings, Numbers, Booleans, and null.
*
* It is possible to write data that will cause inconsistencies in an object structure
* when it is reconstructed. This implementation will resolve inconsistencies as follows:
*
* If data objects are found at a particular path as well as longer paths, the data object
* is returned in a map structure with the special key "@ROOT". This may cause an error
* if the data is later attempted to be deserialized into a POJO.
*
* If list elements are found at the same level as longer paths or a data object, then
* the list elements are returned in a map with the index as keys in the map, e.g. "@0", "@1",
* etc.
*
* If inconsistencies such as these are preventing data from being deserialized into a
* particular POJO, the data can always be retrieved using an instance of (a subclass of) TypeReference<Object>,
* which will return the basic JSON to Java mappings, i.e. Maps, Lists and Strings, etc.
*
* @author Eric Zoerner <a href="mailto:ezoerner@ebuddy.com">ezoerner@ebuddy.com</a>
*/
public class Composer {
private static final Logger log = LoggerFactory.getLogger(Composer.class);
private static final String INCONSISTENT_ROOT = "@ROOT";
private static final Composer INSTANCE = new Composer();
private Composer() { }
public static Composer get() {
return INSTANCE;
}
/**
* Compose a map of simple objects keyed by paths into a single complex object, e.g. a map or list
*
* @param simpleObjects input map of decomposed objects, paths mapped to simple values (i.e. strings, numbers, or booleans)
* @return a complex object such as a map or list decoded from the paths in decomposedObjects
* @throws IllegalArgumentException if there are unsupported objects types in simpleObjects, or
* if there is a key that is an empty path
*/
public Object compose(Map<Path,Object> simpleObjects) {
if (simpleObjects == null) {
throw new IllegalArgumentException("simpleObject is null");
}
if (simpleObjects.isEmpty()) {
return Collections.emptyMap();
}
// if this is a singleton map with an empty path as the key, then this represents itself a
// simple object, so just return the value
if (simpleObjects.size() == 1 && simpleObjects.keySet().iterator().next().size() == 0) {
return simpleObjects.values().iterator().next();
}
// decompose into nested maps by merging the partial map from each path.
// After composing into nested maps, go through the tree structure and transform SortedMaps into Lists.
// The reason for a two-pass approach is that the lists may be "sparse" due to deleted indexes, and
// this is difficult to handle in one pass.
return transformLists(composeMap(simpleObjects));
}
private Map<String,Object> composeMap(Map<Path,Object> simpleObjects) {
Map<String,Object> composition = new LinkedHashMap<String,Object>(simpleObjects.size());
for (Map.Entry<Path,Object> entry : simpleObjects.entrySet()) {
merge(entry, composition);
}
return composition;
}
@SuppressWarnings("unchecked")
private void merge(Map.Entry<Path,Object> simpleEntry, Map<String,Object> compositionMap) {
Path path = simpleEntry.getKey();
String head = path.head();
assert head != null;
Object nextLevelComposition = compositionMap.get(head);
Path tail = path.tail();
Object simpleValue = simpleEntry.getValue();
if (nextLevelComposition == null) {
mergeEntryIntoEmptySlot(compositionMap, head, tail, simpleValue);
} else if (Types.isSimple(nextLevelComposition)) {
mergeEntryWithSimple(compositionMap, nextLevelComposition, head, tail, simpleValue);
} else {
mergeEntryWithStructure((Map<String,Object>)nextLevelComposition, tail, simpleValue);
}
}
private void mergeEntryWithStructure(Map<String,Object> nextLevelComposition, Path tail, Object simpleValue) {
if (tail.isEmpty()) {
// INCONSISTENCY!! there is a simple value at the same level as a complex object
// Resolve this by putting this value at the special key "@ROOT" inside the complex object.
Object previousValue = nextLevelComposition.put(INCONSISTENT_ROOT, simpleValue);
// not possible to have a previous value, there can only be one key with this path
if (previousValue != null) {
// merging two simple values at same level, this cannot happen because map keys are unique
throw new IllegalStateException("two simple values at same level?");
}
} else {
// simply advance to next level since the head matches a key already there
merge(new SimpleEntry<Path,Object>(tail, simpleValue), nextLevelComposition);
}
}
private void mergeEntryIntoEmptySlot(Map<String,Object> composition, String head, Path tail, Object simpleValue) {
if (tail.isEmpty()) {
composition.put(head, simpleValue);
} else {
composition.put(head, composeMap(Collections.singletonMap(tail, simpleValue)));
}
}
private void mergeEntryWithSimple(Map<String,Object> composition,
Object nextLevelComposition,
String head,
Path tail,
Object simpleValue) {
if (tail.isEmpty()) {
// merging two simple values at same level, this cannot happen because map keys are unique
throw new IllegalStateException("two simple values at same level?");
}
// merging longer path with simple value
Map<String,Object> map = composeMap(Collections.singletonMap(tail, simpleValue));
map.put(INCONSISTENT_ROOT, nextLevelComposition);
// replace the simple value with map containing simple value and inconsistent root
composition.put(head, map);
}
@SuppressWarnings("unchecked")
private Object transformLists(Map<String,Object> map) {
// go through nested maps and transform maps into lists where possible
if (DefaultPath.isList(map.keySet())) {
return transformActualList(map);
}
// if not a list, then just recursively transform the structure, and also URL-Decode the keys
Map<String,Object> newMap = new HashMap<String,Object>(map.size());
for (Map.Entry<String,Object> entry : map.entrySet()) {
Object value = entry.getValue();
if (value instanceof Map) {
Object transformedValue = transformLists((Map<String,Object>)value);
newMap.put(urlDecode(entry.getKey()), transformedValue);
} else if (Types.isSimple(value)) {
newMap.put(urlDecode(entry.getKey()), entry.getValue());
} else {
throw new IllegalStateException("found strange object in structure: " + value);
}
}
return newMap;
}
@SuppressWarnings("unchecked")
private Object transformActualList(Map<String,Object> map) {
SortedMap<Integer,Object> sortedMap = new TreeMap<Integer,Object>();
List<Object> list = new ArrayList<Object>(map.size());
int listSize = -1;
// convert keys into integer indexes and sort
for (Map.Entry<String,Object> entry : map.entrySet()) {
Object value = entry.getValue();
int listIndex = DefaultPath.getListIndex(entry.getKey());
if (Types.isListTerminator(value)) {
listSize = listSize == -1 ? listIndex : Math.min(listIndex, listSize);
continue;
}
Object transformedValue;
if (Types.isSimple(value)) {
transformedValue = value;
} else if (value instanceof Map) {
transformedValue = transformLists((Map<String,Object>)value);
} else {
throw new IllegalStateException("found strange object in structure: " + value);
}
sortedMap.put(DefaultPath.getListIndex(entry.getKey()), transformedValue);
}
// if no listSize was found then something went wrong, but just warn and use whole list found
if (listSize == -1) {
log.warn("no list terminator found, using all list elements");
}
// copy values into list in sorted order
int index = 0;
for (Object value : sortedMap.values()) {
if (index == listSize) {
break;
}
list.add(value);
index++;
}
return list;
}
private String urlDecode(String encodedString) {
String decodedString;
try {
decodedString = URLDecoder.decode(encodedString, "UTF-8");
} catch (UnsupportedEncodingException ignored) {
throw new AssertionError("UTF-8 is unknown?");
}
return decodedString;
}
}