/* * Copyright 2017 Google Inc. * * 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.google.firebase.database.utilities.encoding; import static com.google.firebase.database.utilities.Utilities.hardAssert; import com.google.firebase.database.DatabaseException; import com.google.firebase.database.Exclude; import com.google.firebase.database.GenericTypeIndicator; import com.google.firebase.database.IgnoreExtraProperties; import com.google.firebase.database.PropertyName; import com.google.firebase.database.ThrowOnExtraProperties; import com.google.firebase.internal.Log; import java.lang.reflect.AccessibleObject; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.GenericArrayType; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.lang.reflect.TypeVariable; import java.lang.reflect.WildcardType; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; /** Helper class to convert to/from custom POJO classes and plain Java types. */ public class CustomClassMapper { private static final String LOG_TAG = "ClassMapper"; private static final ConcurrentMap<Class<?>, BeanMapper<?>> mappers = new ConcurrentHashMap<>(); /** * Converts a Java representation of JSON data to standard library Java data types: Map, Array, * String, Double, Integer and Boolean. POJOs are converted to Java Maps. * * @param object The representation of the JSON data * @return JSON representation containing only standard library Java types */ public static Object convertToPlainJavaTypes(Object object) { return serialize(object); } @SuppressWarnings("unchecked") public static Map<String, Object> convertToPlainJavaTypes(Map<String, Object> update) { Object converted = serialize(update); hardAssert(converted instanceof Map); return (Map<String, Object>) converted; } /** * Converts a standard library Java representation of JSON data to an object of the provided * class. * * @param object The representation of the JSON data * @param clazz The class of the object to convert to * @return The POJO object. */ public static <T> T convertToCustomClass(Object object, Class<T> clazz) { return deserializeToClass(object, clazz); } /** * Converts a standard library Java representation of JSON data to an object of the class provided * through the GenericTypeIndicator * * @param object The representation of the JSON data * @param typeIndicator The indicator providing class of the object to convert to * @return The POJO object. */ public static <T> T convertToCustomClass(Object object, GenericTypeIndicator<T> typeIndicator) { Class<?> clazz = typeIndicator.getClass(); Type genericTypeIndicatorType = clazz.getGenericSuperclass(); if (genericTypeIndicatorType instanceof ParameterizedType) { ParameterizedType parameterizedType = (ParameterizedType) genericTypeIndicatorType; if (!parameterizedType.getRawType().equals(GenericTypeIndicator.class)) { throw new DatabaseException( "Not a direct subclass of GenericTypeIndicator: " + genericTypeIndicatorType); } // We are guaranteed to have exactly one type parameter Type type = parameterizedType.getActualTypeArguments()[0]; return deserializeToType(object, type); } else { throw new DatabaseException( "Not a direct subclass of GenericTypeIndicator: " + genericTypeIndicatorType); } } @SuppressWarnings("unchecked") private static <T> Object serialize(T obj) { if (obj == null) { return null; } else if (obj instanceof Number) { if (obj instanceof Float) { return ((Float) obj).doubleValue(); } else if (obj instanceof Short) { throw new DatabaseException("Shorts are not supported, please use int or long"); } else if (obj instanceof Byte) { throw new DatabaseException("Bytes are not supported, please use int or long"); } else { // Long, Integer, Double return obj; } } else if (obj instanceof String) { return obj; } else if (obj instanceof Boolean) { return obj; } else if (obj instanceof Character) { throw new DatabaseException("Characters are not supported, please strings"); } else if (obj instanceof Map) { Map<String, Object> result = new HashMap<>(); for (Map.Entry<Object, Object> entry : ((Map<Object, Object>) obj).entrySet()) { Object key = entry.getKey(); if (key instanceof String) { String keyString = (String) key; result.put(keyString, serialize(entry.getValue())); } else { throw new DatabaseException("Maps with non-string keys are not supported"); } } return result; } else if (obj instanceof Collection) { if (obj instanceof List) { List<Object> list = (List<Object>) obj; List<Object> result = new ArrayList<>(list.size()); for (Object object : list) { result.add(serialize(object)); } return result; } else { throw new DatabaseException( "Serializing Collections is not supported, " + "please use Lists instead"); } } else if (obj.getClass().isArray()) { throw new DatabaseException( "Serializing Arrays is not supported, please use Lists " + "instead"); } else if (obj instanceof Enum) { return ((Enum<?>) obj).name(); } else { Class<T> clazz = (Class<T>) obj.getClass(); BeanMapper<T> mapper = loadOrCreateBeanMapperForClass(clazz); return mapper.serialize(obj); } } @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) private static <T> T deserializeToType(Object obj, Type type) { if (obj == null) { return null; } else if (type instanceof ParameterizedType) { return deserializeToParameterizedType(obj, (ParameterizedType) type); } else if (type instanceof Class) { return deserializeToClass(obj, (Class<T>) type); } else if (type instanceof WildcardType) { throw new DatabaseException("Generic wildcard types are not supported"); } else if (type instanceof GenericArrayType) { throw new DatabaseException( "Generic Arrays are not supported, please use Lists " + "instead"); } else { throw new IllegalStateException("Unknown type encountered: " + type); } } @SuppressWarnings("unchecked") private static <T> T deserializeToClass(Object obj, Class<T> clazz) { if (obj == null) { return null; } else if (clazz.isPrimitive() || Number.class.isAssignableFrom(clazz) || Boolean.class.isAssignableFrom(clazz) || Character.class.isAssignableFrom(clazz)) { return deserializeToPrimitive(obj, clazz); } else if (String.class.isAssignableFrom(clazz)) { return (T) convertString(obj); } else if (clazz.isArray()) { throw new DatabaseException( "Converting to Arrays is not supported, please use Lists" + "instead"); } else if (clazz.getTypeParameters().length > 0) { throw new DatabaseException( "Class " + clazz.getName() + " has generic type " + "parameters, please use GenericTypeIndicator instead"); } else if (clazz.equals(Object.class)) { return (T) obj; } else if (clazz.isEnum()) { return deserializeToEnum(obj, clazz); } else { return convertBean(obj, clazz); } } @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) private static <T> T deserializeToParameterizedType(Object obj, ParameterizedType type) { // getRawType should always return a Class<?> Class<?> rawType = (Class<?>) type.getRawType(); if (List.class.isAssignableFrom(rawType)) { Type genericType = type.getActualTypeArguments()[0]; if (obj instanceof List) { List<Object> list = (List<Object>) obj; List<Object> result = new ArrayList<>(list.size()); for (Object object : list) { result.add(deserializeToType(object, genericType)); } return (T) result; } else { throw new DatabaseException( "Expected a List while deserializing, but got a " + obj.getClass()); } } else if (Map.class.isAssignableFrom(rawType)) { Type keyType = type.getActualTypeArguments()[0]; Type valueType = type.getActualTypeArguments()[1]; if (!keyType.equals(String.class)) { throw new DatabaseException( "Only Maps with string keys are supported, " + "but found Map with key type " + keyType); } Map<String, Object> map = expectMap(obj); HashMap<String, Object> result = new HashMap<>(); for (Map.Entry<String, Object> entry : map.entrySet()) { result.put(entry.getKey(), deserializeToType(entry.getValue(), valueType)); } return (T) result; } else if (Collection.class.isAssignableFrom(rawType)) { throw new DatabaseException("Collections are not supported, please use Lists instead"); } else { Map<String, Object> map = expectMap(obj); BeanMapper<T> mapper = (BeanMapper<T>) loadOrCreateBeanMapperForClass(rawType); HashMap<TypeVariable<Class<T>>, Type> typeMapping = new HashMap<>(); TypeVariable<Class<T>>[] typeVariables = mapper.clazz.getTypeParameters(); Type[] types = type.getActualTypeArguments(); if (types.length != typeVariables.length) { throw new IllegalStateException("Mismatched lengths for type variables and actual types"); } for (int i = 0; i < typeVariables.length; i++) { typeMapping.put(typeVariables[i], types[i]); } return mapper.deserialize(map, typeMapping); } } @SuppressWarnings("unchecked") private static <T> T deserializeToPrimitive(Object obj, Class<T> clazz) { if (Integer.class.isAssignableFrom(clazz) || int.class.isAssignableFrom(clazz)) { return (T) convertInteger(obj); } else if (Boolean.class.isAssignableFrom(clazz) || boolean.class.isAssignableFrom(clazz)) { return (T) convertBoolean(obj); } else if (Double.class.isAssignableFrom(clazz) || double.class.isAssignableFrom(clazz)) { return (T) convertDouble(obj); } else if (Long.class.isAssignableFrom(clazz) || long.class.isAssignableFrom(clazz)) { return (T) convertLong(obj); } else if (Float.class.isAssignableFrom(clazz) || float.class.isAssignableFrom(clazz)) { return (T) (Float) convertDouble(obj).floatValue(); } else if (Short.class.isAssignableFrom(clazz) || short.class.isAssignableFrom(clazz)) { throw new DatabaseException("Deserializing to shorts is not supported"); } else if (Byte.class.isAssignableFrom(clazz) || byte.class.isAssignableFrom(clazz)) { throw new DatabaseException("Deserializing to bytes is not supported"); } else if (Character.class.isAssignableFrom(clazz) || char.class.isAssignableFrom(clazz)) { throw new DatabaseException("Deserializing to char is not supported"); } else { throw new IllegalArgumentException("Unknown primitive type: " + clazz); } } @SuppressWarnings("unchecked") private static <T> T deserializeToEnum(Object object, Class<T> clazz) { if (object instanceof String) { String value = (String) object; // We cast to Class without generics here since we can't prove the bound // T extends Enum<T> statically try { return (T) Enum.valueOf((Class) clazz, value); } catch (IllegalArgumentException e) { throw new DatabaseException( "Could not find enum value of " + clazz.getName() + " for value \"" + value + "\""); } } else { throw new DatabaseException( "Expected a String while deserializing to enum " + clazz + " but got a " + object.getClass()); } } @SuppressWarnings("unchecked") private static <T> BeanMapper<T> loadOrCreateBeanMapperForClass(Class<T> clazz) { BeanMapper<T> mapper = (BeanMapper<T>) mappers.get(clazz); if (mapper == null) { mapper = new BeanMapper<>(clazz); // Inserting without checking is fine because mappers are "pure" and it's okay // if we create and use multiple by different threads temporarily mappers.put(clazz, mapper); } return mapper; } @SuppressWarnings("unchecked") private static Map<String, Object> expectMap(Object object) { if (object instanceof Map) { // TODO: runtime validation of keys? return (Map<String, Object>) object; } else { throw new DatabaseException( "Expected a Map while deserializing, but got a " + object.getClass()); } } private static Integer convertInteger(Object obj) { if (obj instanceof Integer) { return (Integer) obj; } else if (obj instanceof Long || obj instanceof Double) { double value = ((Number) obj).doubleValue(); if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { return ((Number) obj).intValue(); } else { throw new DatabaseException( "Numeric value out of 32-bit integer range: " + value + ". Did you mean to use a long or double instead of an int?"); } } else { throw new DatabaseException( "Failed to convert a value of type " + obj.getClass().getName() + " to int"); } } private static Long convertLong(Object obj) { if (obj instanceof Integer) { return ((Integer) obj).longValue(); } else if (obj instanceof Long) { return (Long) obj; } else if (obj instanceof Double) { Double value = (Double) obj; if (value >= Long.MIN_VALUE && value <= Long.MAX_VALUE) { return value.longValue(); } else { throw new DatabaseException( "Numeric value out of 64-bit long range: " + value + ". Did you mean to use a double instead of a long?"); } } else { throw new DatabaseException( "Failed to convert a value of type " + obj.getClass().getName() + " to long"); } } private static Double convertDouble(Object obj) { if (obj instanceof Integer) { return ((Integer) obj).doubleValue(); } else if (obj instanceof Long) { Long value = (Long) obj; Double doubleValue = ((Long) obj).doubleValue(); if (doubleValue.longValue() == value) { return doubleValue; } else { throw new DatabaseException( "Loss of precision while converting number to " + "double: " + obj + ". Did you mean to use a 64-bit long instead?"); } } else if (obj instanceof Double) { return (Double) obj; } else { throw new DatabaseException( "Failed to convert a value of type " + obj.getClass().getName() + " to double"); } } private static Boolean convertBoolean(Object obj) { if (obj instanceof Boolean) { return (Boolean) obj; } else { throw new DatabaseException( "Failed to convert value of type " + obj.getClass().getName() + " to boolean"); } } private static String convertString(Object obj) { if (obj instanceof String) { return (String) obj; } else { throw new DatabaseException( "Failed to convert value of type " + obj.getClass().getName() + " to String"); } } private static <T> T convertBean(Object obj, Class<T> clazz) { BeanMapper<T> mapper = loadOrCreateBeanMapperForClass(clazz); if (obj instanceof Map) { return mapper.deserialize(expectMap(obj)); } else { throw new DatabaseException( "Can't convert object of type " + obj.getClass().getName() + " to type " + clazz.getName()); } } private static class BeanMapper<T> { private final Class<T> clazz; private final Constructor<T> constructor; private final boolean throwOnUnknownProperties; private final boolean warnOnUnknownProperties; // Case insensitive mapping of properties to their case sensitive versions private final Map<String, String> properties; private final Map<String, Method> getters; private final Map<String, Method> setters; private final Map<String, Field> fields; public BeanMapper(Class<T> clazz) { this.clazz = clazz; this.throwOnUnknownProperties = clazz.isAnnotationPresent(ThrowOnExtraProperties.class); this.warnOnUnknownProperties = !clazz.isAnnotationPresent(IgnoreExtraProperties.class); this.properties = new HashMap<>(); this.setters = new HashMap<>(); this.getters = new HashMap<>(); this.fields = new HashMap<>(); Constructor<T> constructor = null; try { constructor = clazz.getDeclaredConstructor(); constructor.setAccessible(true); } catch (NoSuchMethodException e) { // We will only fail at deserialization time if no constructor is present constructor = null; } this.constructor = constructor; // Add any public getters to properties (including isXyz()) for (Method method : clazz.getMethods()) { if (shouldIncludeGetter(method)) { String propertyName = propertyName(method); addProperty(propertyName); method.setAccessible(true); if (getters.containsKey(propertyName)) { throw new DatabaseException("Found conflicting getters for name: " + method.getName()); } getters.put(propertyName, method); } } // Add any public fields to properties for (Field field : clazz.getFields()) { if (shouldIncludeField(field)) { String propertyName = propertyName(field); addProperty(propertyName); } } // We can use private setters and fields for known (public) properties/getters. Since // getMethods/getFields only returns public methods/fields we need to traverse the // class hierarchy to find the appropriate setter or field. Class<? super T> currentClass = clazz; do { // Add any setters for (Method method : currentClass.getDeclaredMethods()) { if (shouldIncludeSetter(method)) { String propertyName = propertyName(method); String existingPropertyName = properties.get(propertyName.toLowerCase()); if (existingPropertyName != null) { if (!existingPropertyName.equals(propertyName)) { throw new DatabaseException( "Found setter with invalid case-sensitive name: " + method.getName()); } else { Method existingSetter = setters.get(propertyName); if (existingSetter == null) { method.setAccessible(true); setters.put(propertyName, method); } else if (!isSetterOverride(method, existingSetter)) { // We require that setters with conflicting property names are // overrides from a base class throw new DatabaseException( "Found a conflicting setters " + "with name: " + method.getName() + " (conflicts with " + existingSetter.getName() + " defined on " + existingSetter.getDeclaringClass().getName() + ")"); } } } } } for (Field field : currentClass.getDeclaredFields()) { String propertyName = propertyName(field); // Case sensitivity is checked at deserialization time // Fields are only added if they don't exist on a subclass if (properties.containsKey(propertyName.toLowerCase()) && !fields.containsKey(propertyName)) { field.setAccessible(true); fields.put(propertyName, field); } } // Traverse class hierarchy until we reach java.lang.Object which contains a bunch // of fields/getters we don't want to serialize currentClass = currentClass.getSuperclass(); } while (currentClass != null && !currentClass.equals(Object.class)); if (properties.isEmpty()) { throw new DatabaseException("No properties to serialize found on class " + clazz.getName()); } } private static boolean shouldIncludeGetter(Method method) { if (!method.getName().startsWith("get") && !method.getName().startsWith("is")) { return false; } // Exclude methods from Object.class if (method.getDeclaringClass().equals(Object.class)) { return false; } // Non-public methods if (!Modifier.isPublic(method.getModifiers())) { return false; } // Static methods if (Modifier.isStatic(method.getModifiers())) { return false; } // No return type if (method.getReturnType().equals(Void.TYPE)) { return false; } // Non-zero parameters if (method.getParameterTypes().length != 0) { return false; } // Excluded methods if (method.isAnnotationPresent(Exclude.class)) { return false; } return true; } private static boolean shouldIncludeSetter(Method method) { if (!method.getName().startsWith("set")) { return false; } // Exclude methods from Object.class if (method.getDeclaringClass().equals(Object.class)) { return false; } // Static methods if (Modifier.isStatic(method.getModifiers())) { return false; } // Has a return type if (!method.getReturnType().equals(Void.TYPE)) { return false; } // Methods without exactly one parameters if (method.getParameterTypes().length != 1) { return false; } // Excluded methods if (method.isAnnotationPresent(Exclude.class)) { return false; } return true; } private static boolean shouldIncludeField(Field field) { // Exclude methods from Object.class if (field.getDeclaringClass().equals(Object.class)) { return false; } // Non-public fields if (!Modifier.isPublic(field.getModifiers())) { return false; } // Static fields if (Modifier.isStatic(field.getModifiers())) { return false; } // Transient fields if (Modifier.isTransient(field.getModifiers())) { return false; } // Excluded fields if (field.isAnnotationPresent(Exclude.class)) { return false; } return true; } private static boolean isSetterOverride(Method base, Method override) { // We expect an overridden setter here hardAssert( base.getDeclaringClass().isAssignableFrom(override.getDeclaringClass()), "Expected override from a base class"); hardAssert(base.getReturnType().equals(Void.TYPE), "Expected void return type"); hardAssert(override.getReturnType().equals(Void.TYPE), "Expected void return type"); Type[] baseParameterTypes = base.getParameterTypes(); Type[] overrideParameterTypes = override.getParameterTypes(); hardAssert(baseParameterTypes.length == 1, "Expected exactly one parameter"); hardAssert(overrideParameterTypes.length == 1, "Expected exactly one parameter"); return base.getName().equals(override.getName()) && baseParameterTypes[0].equals(overrideParameterTypes[0]); } private static String propertyName(Field field) { String annotatedName = annotatedName(field); return annotatedName != null ? annotatedName : field.getName(); } private static String propertyName(Method method) { String annotatedName = annotatedName(method); return annotatedName != null ? annotatedName : serializedName(method.getName()); } private static String annotatedName(AccessibleObject obj) { if (obj.isAnnotationPresent(PropertyName.class)) { PropertyName annotation = obj.getAnnotation(PropertyName.class); return annotation.value(); } return null; } private static String serializedName(String methodName) { String[] prefixes = new String[] {"get", "set", "is"}; String methodPrefix = null; for (String prefix : prefixes) { if (methodName.startsWith(prefix)) { methodPrefix = prefix; } } if (methodPrefix == null) { throw new IllegalArgumentException("Unknown Bean prefix for method: " + methodName); } String strippedName = methodName.substring(methodPrefix.length()); // Make sure the first word or upper-case prefix is converted to lower-case char[] chars = strippedName.toCharArray(); int pos = 0; while (pos < chars.length && Character.isUpperCase(chars[pos])) { chars[pos] = Character.toLowerCase(chars[pos]); pos++; } return new String(chars); } private void addProperty(String property) { String oldValue = this.properties.put(property.toLowerCase(), property); if (oldValue != null && !property.equals(oldValue)) { throw new DatabaseException( "Found two getters or fields with conflicting case " + "sensitivity for property: " + property.toLowerCase()); } } public T deserialize(Map<String, Object> values) { return deserialize(values, Collections.<TypeVariable<Class<T>>, Type>emptyMap()); } public T deserialize(Map<String, Object> values, Map<TypeVariable<Class<T>>, Type> types) { if (this.constructor == null) { throw new DatabaseException( "Class " + this.clazz.getName() + " is missing a constructor with no arguments"); } T instance; try { instance = this.constructor.newInstance(); } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } for (Map.Entry<String, Object> entry : values.entrySet()) { String propertyName = entry.getKey(); if (this.setters.containsKey(propertyName)) { Method setter = this.setters.get(propertyName); Type[] params = setter.getGenericParameterTypes(); if (params.length != 1) { throw new IllegalStateException("Setter does not have exactly one " + "parameter"); } Type resolvedType = resolveType(params[0], types); Object value = CustomClassMapper.deserializeToType(entry.getValue(), resolvedType); try { setter.invoke(instance, value); } catch (IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } } else if (this.fields.containsKey(propertyName)) { Field field = this.fields.get(propertyName); Type resolvedType = resolveType(field.getGenericType(), types); Object value = CustomClassMapper.deserializeToType(entry.getValue(), resolvedType); try { field.set(instance, value); } catch (IllegalAccessException e) { throw new RuntimeException(e); } } else { String message = "No setter/field for " + propertyName + " found " + "on class " + this.clazz.getName(); if (this.properties.containsKey(propertyName.toLowerCase())) { message += " (fields/setters are case sensitive!)"; } if (this.throwOnUnknownProperties) { throw new DatabaseException(message); } else if (this.warnOnUnknownProperties) { // TODO: replace Android logging with "our" logging Log.w(LOG_TAG, message); } } } return instance; } private Type resolveType(Type type, Map<TypeVariable<Class<T>>, Type> types) { if (type instanceof TypeVariable) { Type resolvedType = types.get(type); if (resolvedType == null) { throw new IllegalStateException("Could not resolve type " + type); } else { return resolvedType; } } else { return type; } } public Map<String, Object> serialize(T object) { if (!clazz.isAssignableFrom(object.getClass())) { throw new IllegalArgumentException( "Can't serialize object of class " + object.getClass() + " with BeanMapper for class " + clazz); } Map<String, Object> result = new HashMap<>(); for (String property : this.properties.values()) { Object propertyValue; if (this.getters.containsKey(property)) { Method getter = this.getters.get(property); try { propertyValue = getter.invoke(object); } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (InvocationTargetException e) { throw new RuntimeException(e); } } else { // Must be a field Field field = this.fields.get(property); if (field == null) { throw new IllegalStateException("Bean property without field or getter:" + property); } try { propertyValue = field.get(object); } catch (IllegalAccessException e) { throw new RuntimeException(e); } } Object serializedValue = CustomClassMapper.serialize(propertyValue); result.put(property, serializedValue); } return result; } } }