Introspector.java

package org.andromda.core.common;

import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.exception.ExceptionUtils;
import org.apache.log4j.Logger;

/**
 * A simple class providing the ability to manipulate properties on java bean objects.
 *
 * @author Chad Brandon
 * @author Bob Fields
 */
public final class Introspector
{
    /**
     * The shared instance.
     */
    private static Introspector instance = null;

    /**
     * Gets the shared instance.
     *
     * @return the shared introspector instance.
     */
    public static Introspector instance()
    {
        if (instance == null)
        {
            instance = new Introspector();
        }
        return instance;
    }

    /**
     * The logger instance.
     */
    private static final Logger logger = Logger.getLogger(Introspector.class);

    /**
     * <p> Indicates whether or not the given <code>object</code> contains a
     * valid property with the given <code>name</code> and <code>value</code>.
     * </p>
     * <p>
     * A valid property means the following:
     * <ul>
     * <li>It exists on the object</li>
     * <li>It is not null on the object</li>
     * <li>If its a boolean value, then it evaluates to <code>true</code></li>
     * <li>If value is not null, then the property matches the given </code>.value</code></li>
     * </ul>
     * All other possibilities return <code>false</code>
     * </p>
     *
     * @param object the object to test for the valid property.
     * @param name the name of the property for which to test.
     * @param value the value to evaluate against.
     * @return true/false
     */
    public boolean containsValidProperty(
        final Object object,
        final String name,
        final String value)
    {
        boolean valid;

        try
        {
            final Object propertyValue = this.getProperty(
                    object,
                    name);
            valid = propertyValue != null;

            // if valid is still true, and the propertyValue
            // is not null
            if (valid)
            {
                // if it's a collection then we check to see if the
                // collection is not empty
                if (propertyValue instanceof Collection)
                {
                    valid = !((Collection)propertyValue).isEmpty();
                }
                else
                {
                    final String valueAsString = String.valueOf(propertyValue);
                    if (StringUtils.isNotBlank(value))
                    {
                        valid = valueAsString.equals(value);
                    }
                    else if (propertyValue instanceof Boolean)
                    {
                        valid = Boolean.valueOf(valueAsString);
                    }
                }
            }
        }
        catch (final Throwable throwable)
        {
            valid = false;
        }
        return valid;
    }

    /**
     * Sets the property having the given <code>name</code> on the <code>object</code>
     * with the given <code>value</code>.
     *
     * @param object the object on which to set the property.
     * @param name the name of the property to populate.
     * @param value the value to give the property.
     */
    public void setProperty(
        final Object object,
        final String name,
        final Object value)
    {
        this.setNestedProperty(
            object,
            name,
            value);
    }

    /**
     * The delimiter used for separating nested properties.
     */
    private static final char NESTED_DELIMITER = '.';

    /**
     * Attempts to set the nested property with the given
     * name of the given object.
     * @param object the object on which to populate the property.
     * @param name the name of the object.
     * @param value the value to populate.
     */
    private void setNestedProperty(
        final Object object,
        String name,
        final Object value)
    {
        if (object != null && StringUtils.isNotBlank(name))
        {
            final int dotIndex = name.indexOf(NESTED_DELIMITER);
            if (dotIndex >= name.length())
            {
                throw new IntrospectorException("Invalid property call --> '" + name + '\'');
            }
            String[] names = name.split("\\" + NESTED_DELIMITER);
            Object objectToPopulate = object;
            for (int ctr = 0; ctr < names.length; ctr++)
            {
                name = names[ctr];
                if (ctr == names.length - 1)
                {
                    break;
                }
                objectToPopulate = this.internalGetProperty(
                        objectToPopulate,
                        name);
            }
            this.internalSetProperty(
                objectToPopulate,
                name,
                value);
        }
    }

    /**
     * Attempts to retrieve the property with the given <code>name</code> on the <code>object</code>.
     *
     * @param object the object to which the property belongs.
     * @param name the name of the property
     * @return the value of the property.
     */
    public final Object getProperty(
        final Object object,
        final String name)
    {
        Object result;

        try
        {
            result = this.getNestedProperty(
                    object,
                    name);
        }
        catch (final NullPointerException ex)
        {
            return "null";
        }
        catch (final StackOverflowError ex)
        {
            return "StackOverflowError";
        }
        catch (final IntrospectorException throwable)
        {
            // Don't catch our own exceptions.
            // Otherwise get Exception/Cause chain which
            // can hide the original exception.
            throw throwable;
        }
        catch (Throwable throwable)
        {
            throwable = ExceptionUtils.getRootCause(throwable);

            // If cause is an IntrospectorException re-throw that exception
            // rather than creating a new one.
            if (throwable instanceof IntrospectorException)
            {
                throw (IntrospectorException)throwable;
            }
            throw new IntrospectorException(throwable);
        }
        return result;
    }

    /**
     * Gets a nested property, that is it gets the properties
     * separated by '.'.
     *
     * @param object the object from which to retrieve the nested property.
     * @param name the name of the property
     * @return the property value or null if one couldn't be retrieved.
     */
    private Object getNestedProperty(
        final Object object,
        final String name)
    {
        Object property = null;
        if (object != null && StringUtils.isNotBlank(name))
        {
            int dotIndex = name.indexOf(NESTED_DELIMITER);
            if (dotIndex == -1)
            {
                property = this.internalGetProperty(
                        object,
                        name);
            }
            else
            {
                if (dotIndex >= name.length())
                {
                    throw new IntrospectorException("Invalid property call --> '" + name + '\'');
                }
                final Object nextInstance = this.internalGetProperty(
                        object,
                        name.substring(
                            0,
                            dotIndex));
                property = getNestedProperty(
                        nextInstance,
                        name.substring(dotIndex + 1));
            }
        }
        return property;
    }

    /**
     * Cache for a class's write methods.
     */
    private final Map<Class, Map<String, Method>> writeMethodsCache = new HashMap<Class, Map<String, Method>>();

    /**
     * Gets the writable method for the property.
     *
     * @param object the object from which to retrieve the property method.
     * @param name the name of the property.
     * @return the property method or null if one wasn't found.
     */
    private Method getWriteMethod(
        final Object object,
        final String name)
    {
        Method writeMethod = null;
        final Class objectClass = object.getClass();
        Map<String, Method> classWriteMethods = this.writeMethodsCache.get(objectClass);
        if (classWriteMethods == null)
        {
            classWriteMethods = new HashMap<String, Method>();
        }
        else
        {
            writeMethod = classWriteMethods.get(name);
        }
        if (writeMethod == null)
        {
            final PropertyDescriptor descriptor = this.getPropertyDescriptor(
                    object.getClass(),
                    name);
            writeMethod = descriptor != null ? descriptor.getWriteMethod() : null;
            if (writeMethod != null)
            {
                classWriteMethods.put(
                    name,
                    writeMethod);
                this.writeMethodsCache.put(
                    objectClass,
                    classWriteMethods);
            }
        }
        return writeMethod;
    }

    /**
     * Indicates if the <code>object</code> has a property that
     * is <em>readable</em> with the given <code>name</code>.
     *
     * @param object the object to check.
     * @param name the property to check for.
     * @return this.getReadMethod(object, name) != null
     */
    public boolean isReadable(
        final Object object,
        final String name)
    {
        return this.getReadMethod(
            object,
            name) != null;
    }

    /**
     * Indicates if the <code>object</code> has a property that
     * is <em>writable</em> with the given <code>name</code>.
     *
     * @param object the object to check.
     * @param name the property to check for.
     * @return this.getWriteMethod(object, name) != null
     */
    public boolean isWritable(
        final Object object,
        final String name)
    {
        return this.getWriteMethod(
            object,
            name) != null;
    }

    /**
     * Cache for a class's read methods.
     */
    private final Map<Class, Map<String, Method>> readMethodsCache = new HashMap<Class, Map<String, Method>>();

    /**
     * Gets the readable method for the property.
     *
     * @param object the object from which to retrieve the property method.
     * @param name the name of the property.
     * @return the property method or null if one wasn't found.
     */
    private Method getReadMethod(
        final Object object,
        final String name)
    {
        Method readMethod = null;
        final Class objectClass = object.getClass();
        Map<String, Method> classReadMethods = this.readMethodsCache.get(objectClass);
        if (classReadMethods == null)
        {
            classReadMethods = new HashMap<String, Method>();
        }
        else
        {
            readMethod = classReadMethods.get(name);
        }
        if (readMethod == null)
        {
            final PropertyDescriptor descriptor = this.getPropertyDescriptor(
                    object.getClass(),
                    name);
            readMethod = descriptor != null ? descriptor.getReadMethod() : null;
            if (readMethod != null)
            {
                classReadMethods.put(
                    name,
                    readMethod);
                this.readMethodsCache.put(
                    objectClass,
                    classReadMethods);
            }
        }
        return readMethod;
    }

    /**
     * The cache of property descriptors.
     */
    private final Map<Class, Map<String, PropertyDescriptor>> propertyDescriptorsCache = new HashMap<Class, Map<String, PropertyDescriptor>>();

    /**
     * The pattern for property names.
     */
    private Pattern propertyNamePattern = Pattern.compile("\\p{Lower}\\p{Upper}.*");

    /**
     * Retrieves the property descriptor for the given type and name of
     * the property.
     *
     * @param type the Class of which we'll attempt to retrieve the property
     * @param name the name of the property.
     * @return the found property descriptor
     */
    private PropertyDescriptor getPropertyDescriptor(
        final Class type,
        final String name)
    {
        PropertyDescriptor propertyDescriptor = null;
        Map<String, PropertyDescriptor> classPropertyDescriptors = this.propertyDescriptorsCache.get(type);
        if (classPropertyDescriptors == null)
        {
            classPropertyDescriptors = new HashMap<String, PropertyDescriptor>();
        }
        else
        {
            propertyDescriptor = classPropertyDescriptors.get(name);
        }

        if (propertyDescriptor == null)
        {
            try
            {
                final PropertyDescriptor[] descriptors =
                    java.beans.Introspector.getBeanInfo(type).getPropertyDescriptors();
                final int descriptorNumber = descriptors.length;
                for (int ctr = 0; ctr < descriptorNumber; ctr++)
                {
                    final PropertyDescriptor descriptor = descriptors[ctr];

                    // - handle names that start with a lowercased letter and have an uppercase as the second letter
                    final String compareName =
                        propertyNamePattern.matcher(name).matches() ? StringUtils.capitalize(name) : name;
                    if (descriptor.getName().equals(compareName))
                    {
                        propertyDescriptor = descriptor;
                        break;
                    }
                }
                if (propertyDescriptor == null && name.indexOf(NESTED_DELIMITER) != -1)
                {
                    int dotIndex = name.indexOf(NESTED_DELIMITER);
                    if (dotIndex >= name.length())
                    {
                        throw new IntrospectorException("Invalid property call --> '" + name + '\'');
                    }
                    final PropertyDescriptor nextInstance =
                        this.getPropertyDescriptor(
                            type,
                            name.substring(
                                0,
                                dotIndex));
                    propertyDescriptor =
                        this.getPropertyDescriptor(
                            nextInstance.getPropertyType(),
                            name.substring(dotIndex + 1));
                }
            }
            catch (final IntrospectionException exception)
            {
                throw new IntrospectorException(exception);
            }
            classPropertyDescriptors.put(
                name,
                propertyDescriptor);
            this.propertyDescriptorsCache.put(
                type,
                classPropertyDescriptors);
        }
        return propertyDescriptor;
    }

    /**
     * Prevents stack-over-flows by storing the objects that
     * are currently being evaluated within {@link #internalGetProperty(Object, String)}.
     */
    private final Map<Object, String> evaluatingObjects = new HashMap<Object, String>();

    /**
     * Attempts to get the value of the property with <code>name</code> on the
     * given <code>object</code> (throws an exception if the property
     * is not readable on the object).
     *
     * @param object the object from which to retrieve the property.
     * @param name the name of the property
     * @return the resulting property value
     */
    private Object internalGetProperty(
        final Object object,
        final String name)
    {
        Object property = null;

        // - prevent stack overflows by checking to make sure
        //   we aren't entering any circular evaluations
        final Object value = this.evaluatingObjects.get(object);
        if (value == null || !value.equals(name))
        {
            this.evaluatingObjects.put(
                object,
                name);
            if (object != null || StringUtils.isNotBlank(name))
            {
                final Method method = this.getReadMethod(
                        object,
                        name);
                if (method == null)
                {
                    throw new IntrospectorException("No readable property named '" + name + "', exists on object '" +
                        object + '\'');
                }
                try
                {
                    property = method.invoke(
                            object,
                            (Object[])null);
                }
                catch (Throwable throwable)
                {
                    if (throwable.getCause()!=null)
                    {
                        throwable = throwable.getCause();
                    }
                    // At least output the location where the error happened, not the entire stack trace.
                    StackTraceElement[] trace = throwable.getStackTrace();
                    String location = " AT " + trace[0].getClassName() + '.' + trace[0].getMethodName() + ':' + trace[0].getLineNumber();
                    if (throwable.getMessage()!=null)
                    {
                        location += ' ' + throwable.getMessage();
                    }
                    logger.error("Introspector " + throwable + " invoking " + object + " METHOD " + method + " WITH " + name + location);
                    // Unrecoverable errors may result in infinite loop, in particular StackOverflowError
                    if (throwable instanceof Exception)
                    {
                        throw new IntrospectorException(throwable);
                    }
                }
            }
            this.evaluatingObjects.remove(object);
        }
        return property;
    }

    /**
     * Attempts to sets the value of the property with <code>name</code> on the
     * given <code>object</code> (throws an exception if the property
     * is not writable on the object).
     *
     * @param object the object from which to retrieve the property.
     * @param name the name of the property to set.
     * @param value the value of the property to set.
     */
    private void internalSetProperty(
        final Object object,
        final String name,
        Object value)
    {
        if (object != null || (StringUtils.isNotBlank(name)))
        {
            Class expectedType = null;
            if (value != null && object != null)
            {
                final PropertyDescriptor descriptor = this.getPropertyDescriptor(
                        object.getClass(),
                        name);
                if (descriptor != null)
                {
                    expectedType = this.getPropertyDescriptor(
                            object.getClass(),
                            name).getPropertyType();
                    value = Converter.convert(
                            value,
                            expectedType);
                }
            }
            final Method method = this.getWriteMethod(
                    object,
                    name);
            if (method == null)
            {
                throw new IntrospectorException("No writeable property named '" + name + "', exists on object '" +
                    object + '\'');
            }
            try
            {
                method.invoke(
                    object,
                    value);
            }
            catch (final Throwable throwable)
            {
                throw new IntrospectorException(throwable);
            }
        }
    }

    /**
     * Shuts this instance down and reclaims
     * any resources used by this instance.
     */
    public void shutdown()
    {
        this.propertyDescriptorsCache.clear();
        this.writeMethodsCache.clear();
        this.readMethodsCache.clear();
        this.evaluatingObjects.clear();
        Introspector.instance = null;
    }

    /**
     * @see java.lang.Object#toString()
     */
    @Override
    public String toString()
    {
        StringBuilder builder = new StringBuilder();
        builder.append(super.toString()).append(" [writeMethodsCache=").append(this.writeMethodsCache)
                .append(", readMethodsCache=").append(this.readMethodsCache)
                .append(", propertyDescriptorsCache=").append(this.propertyDescriptorsCache)
                .append(", propertyNamePattern=").append(this.propertyNamePattern)
                .append(", evaluatingObjects=").append(this.evaluatingObjects).append("]");
        return builder.toString();
    }
}