ClassProperties.java

package org.pojomatic.internal;

import java.lang.reflect.Field;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

import org.pojomatic.Pojomator;
import org.pojomatic.PropertyElement;
import org.pojomatic.NoPojomaticPropertiesException;
import org.pojomatic.annotations.*;

/**
 * The properties of a class used for {@link Pojomator#doHashCode(Object)},
 * {@link Pojomator#doEquals(Object, Object)}, and {@link Pojomator#doToString(Object)}.
 */
public class ClassProperties {
  private static final Pattern ACCESSOR_PATTERN = Pattern.compile("(get|is)\\P{Ll}.*");

  private final Map<PropertyRole, List<PropertyElement>> properties = makeProperties();

  private final Class<?> equalsParentClass;

  private final boolean subclassCannotOverrideEquals;

  private final static SelfPopulatingMap<Class<?>, ClassProperties> INSTANCES =
    new SelfPopulatingMap<Class<?>, ClassProperties>() {
      @Override
      protected ClassProperties create(Class<?> key) {
        return new ClassProperties(key);
      }
    };

  private final static class ClassContributionTracker {
    private Class<?> clazz = Object.class;

    public void noteContribution(Class<?> contributingClass) {
      clazz = contributingClass;
    }

    public Class<?> getMostSpecificContributingClass() {
      return clazz;
    }
  }

  /**
   * Get an instance for the given {@code pojoClass}.  Instances are cached, so calling this method
   * repeatedly is not inefficient.
   * @param pojoClass the class to inspect for properties
   * @return The {@code ClassProperties} for {@code pojoClass}.
   * @throws NoPojomaticPropertiesException if {@code pojoClass} has no properties annotated for use
   * with Pojomatic.
   */
  public static ClassProperties forClass(Class<?> pojoClass) throws NoPojomaticPropertiesException {
    return INSTANCES.get(pojoClass);
  }

  /**
   * Creates an instance for the given {@code pojoClass}.
   *
   * @param pojoClass the class to inspect for properties
   * @throws NoPojomaticPropertiesException if {@code pojoClass} has no properties annotated for use
   * with Pojomatic.
   */
  private ClassProperties(Class<?> pojoClass) throws NoPojomaticPropertiesException {
    if (pojoClass.isInterface()) {
      extractClassProperties(pojoClass, new OverridableMethods(), new ClassContributionTracker());
      equalsParentClass = pojoClass;
    }
    else {
      ClassContributionTracker classContributionTracker = new ClassContributionTracker();
      walkHierarchy(pojoClass, new OverridableMethods(), classContributionTracker);
      equalsParentClass = classContributionTracker.getMostSpecificContributingClass();
    }
    verifyPropertiesNotEmpty(pojoClass);
    subclassCannotOverrideEquals = pojoClass.isAnnotationPresent(SubclassCannotOverrideEquals.class)
      || pojoClass.isInterface();
  }

  /**
   * Gets the properties to use for {@link Pojomator#doEquals(Object, Object)}.
   * @return the properties to use for {@link Pojomator#doEquals(Object, Object)}.
   */
  public Collection<PropertyElement> getEqualsProperties() {
    return properties.get(PropertyRole.EQUALS);
  }

  /**
   * Gets the properties to use for {@link Pojomator#doHashCode(Object)}.
   * @return the properties to use for {@link Pojomator#doHashCode(Object)}.
   */
  public Collection<PropertyElement> getHashCodeProperties() {
    return properties.get(PropertyRole.HASH_CODE);
  }

  /**
   * Gets the properties to use for {@link Pojomator#doToString(Object)}.
   * @return the properties to use for {@link Pojomator#doToString(Object)}.
   */
  public Collection<PropertyElement> getToStringProperties() {
    return properties.get(PropertyRole.TO_STRING);
  }

  /**
   * Get the union of all properties used for any Pojomator methods. The resulting set will have a predictable iteration
   * order: first, the ordered list of elements used for equals, followed by an ordered list of any additional elements
   * used for toString.
   * @return the union of all properties used for any Pojomator methods.
   */
  public Set<PropertyElement> getAllProperties() {
    LinkedHashSet<PropertyElement> allProperties = new LinkedHashSet<>();
    allProperties.addAll(properties.get(PropertyRole.EQUALS));
    allProperties.addAll(properties.get(PropertyRole.TO_STRING));
    return allProperties;
  }

  /**
   * Whether instances of {@code otherClass} are candidates for being equal to instances of
   * the class this {@code ClassProperties} instance was created for.
   * @param otherClass the class to check for compatibility for equals with.
   * @return {@code true} if instances of {@code otherClass} are candidates for being equal to
   * instances of the class this {@code ClassProperties} instance was created for, or {@code false}
   * otherwise.
   */
  public boolean isCompatibleForEquals(Class<?> otherClass) {
    if (!equalsParentClass.isAssignableFrom(otherClass)) {
      return false;
    }
    else {
      if (subclassCannotOverrideEquals) {
        return true;
      }
      else {
        return equalsParentClass.equals(forClass(otherClass).equalsParentClass);
      }
    }
  }

  /**
   * Walk up to the top of the hierarchy of {@code clazz}, then start extracting properties from it, working back down
   * the inheritance chain from parent to child.
   * @param clazz the class to inspect
   * @param overridableMethods used to track which methods can be overridden
   * @param classContributionTracker used to track the most specific class which contributes properties
   */
  private void walkHierarchy(
    Class<?> clazz,
    OverridableMethods overridableMethods,
    ClassContributionTracker classContributionTracker) {
    if (clazz != Object.class) {
      walkHierarchy(clazz.getSuperclass(), overridableMethods, classContributionTracker);
      extractClassProperties(clazz, overridableMethods, classContributionTracker);
      if (clazz.isAnnotationPresent(OverridesEquals.class)) {
        classContributionTracker.noteContribution(clazz);
      }
    }
  }

  private void extractClassProperties(
    Class<?> clazz,
    OverridableMethods overridableMethods,
    ClassContributionTracker classContributionTracker) {
    AutoProperty autoProperty = clazz.getAnnotation(AutoProperty.class);
    final DefaultPojomaticPolicy classPolicy =
      (autoProperty != null) ? autoProperty.policy() : null;
    final AutoDetectPolicy autoDetectPolicy =
      (autoProperty != null) ? autoProperty.autoDetect() : null;

    Map<PropertyRole, Map<String, PropertyElement>> fieldsMap = extractFields(
      clazz, classPolicy, autoDetectPolicy, classContributionTracker);
    Map<PropertyRole, Map<String, PropertyElement>> methodsMap = extractMethods(
      clazz, classPolicy, autoDetectPolicy, overridableMethods, classContributionTracker);
    if (containsValues(fieldsMap) || containsValues(methodsMap)) {
      PropertyClassVisitor propertyClassVisitor = PropertyClassVisitor.visitClass(clazz, fieldsMap, methodsMap);
      if (propertyClassVisitor != null) {
        for (PropertyRole role: PropertyRole.values()) {
          properties.get(role).addAll(propertyClassVisitor.getSortedProperties().get(role));
        }
      }
      else {
        throw new RuntimeException("no class bytes for " + clazz);
      }
    }
  }

  private static boolean containsValues(Map<?, ? extends Map<?, ?>> mapOfMaps) {
    for (Map<?, ?> map: mapOfMaps.values()) {
      if (! map.isEmpty()) {
        return true;
      }
    }
    return false;
  }

  private Map<PropertyRole, Map<String, PropertyElement>> extractMethods(
    Class<?> clazz,
    final DefaultPojomaticPolicy classPolicy,
    final AutoDetectPolicy autoDetectPolicy,
    final OverridableMethods overridableMethods,
    final ClassContributionTracker classContributionTracker) {
    Map<PropertyRole, Map<String, PropertyElement>> propertiesMap = makePropertiesMap();
    for (Method method : clazz.getDeclaredMethods()) {
      if (method.isSynthetic()) {
        continue;
      }
      Property property = method.getAnnotation(Property.class);
      if (isStatic(method)) {
        if (property != null) {
          throw new IllegalArgumentException(
            "Static method " + clazz.getName() + "." + method.getName()
            + "() is annotated with @Property");
        }
        else {
          continue;
        }
      }

      PojomaticPolicy propertyPolicy = null;
      if (property != null) {
        if (!methodSignatureIsAccessor(method)) {
          throw new IllegalArgumentException(
            "Method " + method +
            " is annotated with @Property but either takes arguments or returns void");
        }
        propertyPolicy = property.policy();
      }
      else if (!methodIsAccessor(method)) {
        continue;
      }

      /* add all methods that are explicitly annotated or auto-detected, and not overriding already
       * added methods */
      if (propertyPolicy != null || AutoDetectPolicy.METHOD == autoDetectPolicy) {
        PropertyAccessor propertyAccessor = null;
        for (PropertyRole role : overridableMethods.checkAndMaybeAddRolesToMethod(
          method, PropertyFilter.getRoles(propertyPolicy, classPolicy))) {
          if (propertyAccessor == null) {
            propertyAccessor = new PropertyAccessor(method, getPropertyName(property));
          }
          propertiesMap.get(role).put(method.getName(), propertyAccessor);
          if (PropertyRole.EQUALS == role) {
            classContributionTracker.noteContribution(clazz);
          }
        }
      }
    }
    return propertiesMap;
  }

  private Map<PropertyRole, Map<String, PropertyElement>> extractFields(
    Class<?> clazz,
    final DefaultPojomaticPolicy classPolicy,
    final AutoDetectPolicy autoDetectPolicy,
    final ClassContributionTracker classContributionTracker) {
    Map<PropertyRole, Map<String, PropertyElement>> propertiesMap = makePropertiesMap();
    for (Field field : clazz.getDeclaredFields()) {
      if (field.isSynthetic()) {
        continue;
      }
      Property property = field.getAnnotation(Property.class);
      if (isStatic(field)) {
        if (property != null) {
          throw new IllegalArgumentException(
            "Static field " + clazz.getName() + "." + field.getName()
            + " is annotated with @Property");
        }
        else {
          continue;
        }
      }

      final PojomaticPolicy propertyPolicy = (property != null) ? property.policy() : null;

      /* add all fields that are explicitly annotated or auto-detected */
      if (propertyPolicy != null || AutoDetectPolicy.FIELD == autoDetectPolicy) {
        PropertyField propertyField = null;
        for (PropertyRole role : PropertyFilter.getRoles(propertyPolicy, classPolicy)) {
          if (propertyField == null) {
            propertyField = new PropertyField(field, getPropertyName(property));
          }
          propertiesMap.get(role).put(field.getName(), propertyField);
          if (PropertyRole.EQUALS == role) {
            classContributionTracker.noteContribution(clazz);
          }
        }
      }
    }
    return propertiesMap;
  }

  private void verifyPropertiesNotEmpty(Class<?> pojoClass) {
    for (Collection<PropertyElement> propertyElements : properties.values()) {
      if (!propertyElements.isEmpty()) {
        return;
      }
    }
    throw new NoPojomaticPropertiesException(pojoClass);
  }

  private String getPropertyName(Property property) {
    return property == null ? "" : property.name();
  }

  private static boolean methodIsAccessor(Method method) {
    return methodSignatureIsAccessor(method)
      && isAccessorName(method.getName());
  }

  private static boolean methodSignatureIsAccessor(Method method) {
    return ! Void.TYPE.equals(method.getReturnType())
      && method.getParameterTypes().length == 0;
  }

  private static boolean isAccessorName(String name) {
    return ACCESSOR_PATTERN.matcher(name).matches();
  }

  private static boolean isStatic(Member member) {
    return Modifier.isStatic(member.getModifiers());
  }

  private static Map<PropertyRole, List<PropertyElement>> makeProperties() {
    Map<PropertyRole, List<PropertyElement>> properties =
      new EnumMap<>(PropertyRole.class);
    for (PropertyRole role : PropertyRole.values()) {
      properties.put(role, new ArrayList<PropertyElement>());
    }
    return properties;
  }

  private static Map<PropertyRole, Map<String, PropertyElement>> makePropertiesMap() {
    Map<PropertyRole, Map<String, PropertyElement>> properties =
        new EnumMap<>(PropertyRole.class);
    for (PropertyRole role : PropertyRole.values()) {
      properties.put(role, new LinkedHashMap<String, PropertyElement>());
    }
    return properties;
  }
}