interactivespaces.util.data.dynamic.InterfaceMap.java Source code

Java tutorial

Introduction

Here is the source code for interactivespaces.util.data.dynamic.InterfaceMap.java

Source

/*
 * Copyright (C) 2015 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 interactivespaces.util.data.dynamic;

import static java.lang.reflect.Proxy.getInvocationHandler;
import static java.lang.reflect.Proxy.isProxyClass;

import com.google.common.base.Function;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.primitives.Primitives;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Proxy;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
 * Dynamic object factory. Given an interface and a map, creates an object with that interface with state (JavaBean
 * properties) stored in the map.
 * <p/>
 * In current implementation calls to hashCode() and toString() are delegated to the backing map. Two dynamic objects
 * are equal if they are of the same type (implement the same interface) and have the same state (backing maps are
 * equal).
 * <p/>
 * Primitive getters will throw an exception if the corresponding value is null. One exception is primitive boolean
 * getter, which in this case will return false.
 *
 *
 * @author Oleksandr Kelepko
 */
public final class InterfaceMap {
    /**
     * Prevent instantiation of the utility class.
     */
    private InterfaceMap() {
    }

    /**
     * Reflectively creates an instance of a given interface, that is backed by a given map. Interface must contain
     * JavaBean properties of the following types:
     * <ul>
     * <li>{@link java.lang.Integer} and {@code int}</li>
     * <li>{@link java.lang.Long} and {@code long}</li>
     * <li>{@link java.lang.Double} and {@code double}</li>
     * <li>{@link java.lang.Number}</li>
     * <li>{@link java.lang.Boolean} and {@code boolean}</li>
     * <li>{@link java.lang.String}</li>
     * <li>{@link java.lang.CharSequence}</li>
     * <li>another JavaBean interface that meets these requirements</li>
     * <li>{@link java.util.List} of elements of any of the types above (no wildcards)</li>
     * </ul>
     * <p/>
     * Lists represent live view of a list in the backing map. Element removal is supported, adding elements is not.
     *
     * @param interfaceClass
     *          type of the object to create
     * @param backingMap
     *          backing map, the source of values
     * @param <T>
     *          type of the object to create
     *
     * @return instance of the {@code interfaceClass}, backed by {@code backingMap}
     * @throws java.lang.IllegalArgumentException
     *           if {@code interfaceClass} is not an interface
     * @throws java.lang.NullPointerException
     *           if any argument is {@code null}
     */
    @SuppressWarnings("unchecked")
    public static <T> T createInstance(Class<T> interfaceClass, Map<String, Object> backingMap) {
        return (T) Proxy.newProxyInstance(interfaceClass.getClassLoader(), new Class[] { interfaceClass },
                new BackingMapInvocationHandler(backingMap, interfaceClass));
    }

    /**
     * Create a new instance of the given class with an empty backing map.
     *
     * @param interfaceClass
     *          class of object to create
     * @param <T>
     *          type of object
     *
     * @return new dynamic object instance with empty backing map
     */
    public static <T> T createInstance(Class<T> interfaceClass) {
        Map<String, Object> backingMap = Maps.newConcurrentMap();
        return createInstance(interfaceClass, backingMap);
    }

    /**
     * Is this a dynamic object?
     *
     * @param object
     *          dynamic object to query
     *
     * @return {@code true} if this is, in fact, a dynamic object
     */
    public static boolean isDynamicObject(Object object) {
        if (object == null) {
            return false;
        }
        Class<?> c = object.getClass();
        if (isProxyClass(c)) {
            InvocationHandler handler = getInvocationHandler(object);
            return handler instanceof BackingMapInvocationHandler;
        }
        return false;
    }

    /**
     * What's the type of the given dynamic object?
     *
     * @param dynamicObject
     *          dynamic object to query
     *
     * @return interface class represented by the dynamic object
     */
    public static Class<?> getClass(Object dynamicObject) {
        Preconditions.checkArgument(isDynamicObject(dynamicObject), "Object must be DynamicObject");
        return dynamicObject.getClass().getInterfaces()[0];
    }

    /**
     * Get the backing map for the given dynamic object.
     *
     * @param dynamicObject
     *          object to get map from
     *
     * @return backing map for object
     */
    public static Map<String, Object> getBackingMap(Object dynamicObject) {
        InvocationHandler handler = getInvocationHandler(dynamicObject);
        return ((BackingMapInvocationHandler) handler).backingMap;
    }

    /**
     * Invocation handler that stores state of the object (JavaBean properties) in the backing map. Calls to hashCode()
     * and toString() are delegated to the map.
     */
    static final class BackingMapInvocationHandler implements InvocationHandler {

        /**
         * Empty array for masking null in {@link BackingMapInvocationHandler#invoke}.
         */
        private static final Object[] NO_ARGS = {};

        /**
         * Simple types.
         */
        private static final Collection<Class<?>> SIMPLE_TYPES = ImmutableSet.<Class<?>>builder().add(Object.class)
                .add(Integer.class).add(Integer.TYPE).add(Long.class).add(Long.TYPE).add(Double.class)
                .add(Double.TYPE).add(Number.class).add(Boolean.class).add(Boolean.TYPE).add(String.class)
                .add(CharSequence.class).build();

        /**
         * Converts numbers and some strings into doubles.
         */
        private static final Function<Object, Double> TO_DOUBLE = new ToDouble();

        /**
         * The state of the object. Keys are JavaBean property names, values are objects of the following types:
         * <ul>
         * <li>{@link java.lang.Integer}</li>
         * <li>{@link java.lang.Long}</li>
         * <li>{@link java.lang.Double}</li>
         * <li>{@link java.lang.Boolean}</li>
         * <li>{@link java.lang.String}</li>
         * <li>{@link java.collection.Map} whose keys are Javabean properties whose values are found in this list listed
         * types.</li>
         * <li>{@link java.util.List} of any of this list of types</li>
         * </ul>
         */
        private final Map<String, Object> backingMap;

        /**
         * Type of this dynamic object, is used in equals().
         */
        private final String type;

        /**
         * Constructor.
         *
         * @param backingMap
         *          the map that will reflect the state of the object
         * @param type
         *          type this BackingMapInvocationHandler represents (= the interface it implements)
         */
        private BackingMapInvocationHandler(Map<String, Object> backingMap, Class<?> type) {
            this.backingMap = Preconditions.checkNotNull(backingMap);
            this.type = type.getName();
        }

        @Override
        public Object invoke(Object target, Method method, Object[] args) throws Throwable {
            if (args == null) {
                args = NO_ARGS;
            }

            String methodName = method.getName();

            if (method.getDeclaringClass() == Object.class) {
                if (args.length == 0) {
                    if (methodName.equals("hashCode")) {
                        return backingMap.hashCode();
                    }
                    if (methodName.equals("toString")) {
                        return backingMap.toString();
                    }
                }
                if (args.length == 1 && methodName.equals("equals")
                        && method.getParameterTypes()[0] == Object.class) {
                    if (args[0] != null && isProxyClass(args[0].getClass())) {
                        InvocationHandler handler = getInvocationHandler(args[0]);
                        return (handler instanceof BackingMapInvocationHandler)
                                && type.equals(((BackingMapInvocationHandler) handler).type)
                                && backingMap.equals(((BackingMapInvocationHandler) handler).backingMap);
                    }
                    return false;
                }
            }

            // Getters and setters
            if (isGetter(method, args)) {
                return invokeGetter(method);
            }
            if (isSetter(method, args)) {
                invokeSetter(method, args[0]);
                return null;
            }

            // Unsupported method
            String msg = String.format("Method %s is neither a getter nor a setter.", method);
            throw new UnsupportedOperationException(msg);
        }

        /**
         * Emulates invoking a getter.
         *
         * @param method
         *          method that was called
         *
         * @return result of calling the getter
         */
        private Object invokeGetter(Method method) {
            String property = getPropertyName(method.getName());

            Object result = backingMap.get(property);
            Class<?> returnType = method.getReturnType();

            if (result == null) {
                if (returnType == Boolean.TYPE) {
                    return false;
                }
                if (returnType.isPrimitive()) {
                    String message = String.format("Cannot convert null to %s, required by %s.", returnType,
                            method);
                    throw new IllegalStateException(message);
                }
                return null;
            }

            if (SIMPLE_TYPES.contains(returnType)) {
                // 'simple' type - return as-is
                return checkType(returnType, method, result);
            }

            if (returnType == List.class && result instanceof List) {
                return getList(method, (List) result);
            }

            // Do this AFTER dealing with List.class and BEFORE trying to wrap a Map into a DynamicObject.
            if (returnType.isInstance(result)) {
                return result;
            }

            if (returnType.isInterface() && result instanceof Map) {
                @SuppressWarnings("unchecked")
                Map<String, Object> asMap = (Map) result;
                return createInstance(returnType, asMap);
            }

            String msg = String.format(
                    "Method %s returns an object, but the backing map does not contain an object: %s", method,
                    result);
            throw new IllegalStateException(msg);
        }

        /**
         * Emulates invoking a setter.
         *
         * @param method
         *          method that was called
         * @param arg
         *          value to set
         */
        private void invokeSetter(Method method, Object arg) {
            String property = getPropertyName(method.getName());
            if (arg == null) {
                // NOTE: backingMap may not support null values,
                // so here we remove the entry instead of putting null
                backingMap.remove(property);
            } else if (isProxyClass(arg.getClass())) {
                InvocationHandler handler = getInvocationHandler(arg);
                if (handler instanceof BackingMapInvocationHandler) {
                    BackingMapInvocationHandler dynamicObject = (BackingMapInvocationHandler) handler;
                    backingMap.put(property, dynamicObject.backingMap);
                }
            } else {
                backingMap.put(property, arg);
            }
        }

        /**
         * Transforms a given list into a proper return value of a given method.
         *
         * @param method
         *          method whose return value type to which the list will be converted
         * @param list
         *          list that represents the method's return value
         *
         * @return list that can be returned from the method
         */
        private static List<?> getList(Method method, List list) {
            // Check that element types are right.
            ParameterizedType genericReturnType = (ParameterizedType) method.getGenericReturnType();
            Type[] typeArguments = genericReturnType.getActualTypeArguments();
            // This will fail if there are wildcards (e.g. List<? extends Number>).
            Class<?> typeArgument = (Class<?>) typeArguments[0];

            if (!SIMPLE_TYPES.contains(typeArgument) && !typeArgument.isInterface()) {
                String msg = String.format("Return type %s not supported: %s", typeArgument.getName(), method);
                throw new IllegalStateException(msg);
            }

            if (elementsMatch(list, typeArgument, false)) {
                // Is this a good idea?
                return list;
            }

            // Elements don't match precisely - will try some transformations.

            if (typeArgument == Long.class) {
                // Not attempting to round floats or doubles.
                checkListElements(method, list, Long.class, Integer.class);
                return Lists.transform(list, new ToLong());
            }

            if (typeArgument == Double.class) {
                // Any Number and some Strings ("NaN", "-Infinity", "Infinity") can be converted to a Double.
                checkListElements(method, list, Number.class, String.class);
                return Lists.transform(list, TO_DOUBLE);
            }

            if (typeArgument.isInterface()) {
                @SuppressWarnings("unchecked")
                Function<Object, ?> function = new ToDynamicObject(typeArgument);
                List<Object> newList = Lists.newArrayListWithExpectedSize(list.size());
                for (Object x : list) {
                    newList.add(function.apply(x));
                }
                return Collections.unmodifiableList(newList);
                // The code above *should* be functionally equivalent to:
                // return Lists.transform(list, function);
                // ...but for some reason the Lists.transform version does not work!
            }

            // Run again, but throw this time.
            elementsMatch(list, typeArgument, true);

            // Should never reach here, but just in case...
            String msg = String.format("Can't convert to List<%s>: %s", typeArgument, list);
            throw new IllegalStateException(msg);
        }

        /**
         * Checks whether a list contains elements only of a given type (or null).
         *
         * @param list
         *          elements whose type will be checked
         * @param typeArgument
         *          expected type of the elements in the list
         * @param shouldThrow
         *          {@code true} if the code should throw an exception, otherwise return {@code false}
         *
         * @return {@code true} if all elements of the list match the given type
         */
        private static boolean elementsMatch(List<?> list, Class<?> typeArgument, boolean shouldThrow) {
            int index = 0;
            for (Object element : list) {
                if (element != null && !typeArgument.isInstance(element)) {
                    if (shouldThrow) {
                        String msg = String.format(
                                "Can't convert list argument #%d to %s from incompatible type %s", index,
                                typeArgument, element.getClass());
                        throw new IllegalStateException(msg);
                    }
                    return false;
                }

                index++;
            }
            return true;
        }

        /**
         * Ensure a list contains elements of a given type.
         *
         * @param method
         *          method whose return value is the list
         * @param list
         *          list that is the method's return value
         * @param expectedTypes
         *          expected types of the elements in the list
         */
        private static void checkListElements(Method method, List<?> list, Class<?>... expectedTypes) {
            outer: for (Object element : list) {
                if (element == null) {
                    continue;
                }
                for (Class<?> type : expectedTypes) {
                    if (type.isInstance(element)) {
                        continue outer;
                    }
                }
                String message = String.format(
                        "The backing list contains %s of %s, cannot convert to %s, required by %s.", element,
                        element.getClass().getName(), Arrays.asList(expectedTypes), method);
                throw new IllegalStateException(message);
            }
        }

        /**
         * Ensures that result is assignable to the return type of the method.
         *
         * @param maybePrimitiveReturnType
         *          return type of the method
         * @param method
         *          method
         * @param result
         *          the value that will be returned as the result of method invocation
         *
         * @return result that is assignable to the return type of the method
         */
        private static Object checkType(Class<?> maybePrimitiveReturnType, Method method, Object result) {
            Class<?> returnType = Primitives.wrap(maybePrimitiveReturnType);
            if (returnType.isInstance(result)) {
                return result;
            }
            if (returnType == Long.class && result instanceof Integer) {
                return ((Integer) result).longValue();
            }
            if (returnType == Double.class) {
                Object r = TO_DOUBLE.apply(result);
                if (r != null) {
                    return r;
                }
            }

            // type mismatch
            String msg = String.format(
                    "While invoking '%s': method returns %s but the backing map contains '%s' of %s", method,
                    returnType, result, result.getClass());
            throw new IllegalStateException(msg);
        }

        /**
         * Performs a sanity check that a method is a setter, i.e. has a single parameter.
         *
         * @param method
         *          setter method
         * @param params
         *          list of the method's parameters
         *
         * @return {@code true} if the given method looks like a setter
         * @throws java.lang.UnsupportedOperationException
         *           if the method has a setter-like name, but has number of parameters other than 1 or the parameter is of
         *           a primitive type.
         */
        private static boolean isSetter(Method method, Object[] params) {
            String name = method.getName();
            if (!name.startsWith("set")) {
                return false;
            }
            Class<?> returnType = method.getReturnType();
            if (returnType != void.class && returnType != Void.class) {
                String msg = String.format("Setter method must return void: %s.", method);
                throw new UnsupportedOperationException(msg);
            }
            if (params.length != 1) {
                String msg = String.format("Setter method %s must have exactly 1 parameter: %s", method,
                        Arrays.asList(params));
                throw new UnsupportedOperationException(msg);
            }
            return true;
        }

        /**
         * Performs a sanity check that a method is a getter, e.g. has no parameters.
         *
         * @param method
         *          getter method
         * @param params
         *          list of the method's parameters
         *
         * @return {@code true} if the given method seems like a getter
         * @throws java.lang.UnsupportedOperationException
         *           if the method has a getter-like name, but has parameters or returns a primitive value (except for the
         *           boolean primitive type)
         */
        private static boolean isGetter(Method method, Object[] params) {
            String name = method.getName();
            Class<?> returnType = method.getReturnType();
            boolean getterName = name.startsWith("get")
                    || (name.startsWith("is") && returnType == Boolean.class || returnType == Boolean.TYPE);
            if (!getterName) {
                return false;
            }
            if (params.length != 0) {
                String msg = String.format("Getter method %s must be parameterless: %s", method,
                        Arrays.asList(params));
                throw new UnsupportedOperationException(msg);
            }
            return true;
        }

        /**
         * Retrieves a JavaBean property name from a method name.
         *
         * @param methodName
         *          method name
         *
         * @return property name
         * @throws UnsupportedOperationException
         *           if {@code methodName} does not represent a JavaBean property
         */
        private static String getPropertyName(String methodName) {
            StringBuilder sb = new StringBuilder(methodName);
            // remove 'get' or 'set'
            if (methodName.startsWith("get") || methodName.startsWith("set")) {
                sb.delete(0, "get".length());
            } else if (methodName.startsWith("is")) {
                sb.delete(0, "is".length());
            }
            if (sb.length() == 0) {
                throw new UnsupportedOperationException("Not a JavaBean property: " + methodName);
            }
            sb.setCharAt(0, Character.toLowerCase(sb.charAt(0)));
            return sb.toString();
        }
    }

    /**
     * Converts {@link java.lang.Number Numbers} and some {@link java.lang.String Strings} into {@link java.lang.Double}.
     */
    private static class ToDouble implements Function<Object, Double> {
        /**
         * Special double values as strings.
         */
        private final Map<String, Double> specialDoubleValues = ImmutableMap.<String, Double>builder()
                .put("NaN", Double.NaN).put("-Infinity", Double.NEGATIVE_INFINITY)
                .put("Infinity", Double.POSITIVE_INFINITY).build();

        @Override
        public Double apply(Object o) {
            if (o instanceof Double) {
                return (Double) o;
            }
            if (o instanceof Number) {
                return ((Number) o).doubleValue();
            }
            if (o instanceof String) {
                Double d = specialDoubleValues.get(o);
                if (d != null) {
                    return d;
                }
            }
            String msg = String.format("Cannot convert %s of %s to Double", o, o.getClass());
            throw new IllegalStateException(msg);
        }
    }

    /**
     * Converts {@link java.lang.Long Long}s and {@link java.lang.Integer Integer}s into {@link java.lang.Long}.
     */
    private static class ToLong implements Function<Object, Long> {
        @Override
        public Long apply(Object o) {
            return o instanceof Integer ? ((Integer) o).longValue() : (Long) o;
        }
    }

    /**
     * Converts {@link java.util.Map Map}s into dynamic objects.
     */
    private static class ToDynamicObject<T> implements Function<Object, T> {
        /**
         * The type of the dynamic object to return.
         */
        private final Class<T> type;

        /**
         * Constructor.
         *
         * @param type
         *          type of the object to return
         */
        ToDynamicObject(Class<T> type) {
            this.type = type;
        }

        @Override
        public T apply(Object o) {
            if (o == null || type.isInstance(o)) {
                return type.cast(o);
            }
            if (o instanceof Map) {
                @SuppressWarnings("unchecked")
                Map<String, Object> asMap = (Map<String, Object>) o;
                return createInstance(type, asMap);
            }
            String msg = String.format("Can't convert to %s: %s [%s]", type.getName(), o, o.getClass().getName());
            throw new IllegalStateException(msg);
        }
    }
}