package org.ytoh.configurations.util;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.collections.Predicate;
import org.apache.log4j.Logger;
import org.hibernate.validator.engine.ConstraintValidatorFactoryImpl;
import org.ytoh.configurations.*;
import org.ytoh.configurations.annotations.Editor;
import org.ytoh.configurations.annotations.Renderer;
import org.ytoh.configurations.context.Context;
import org.ytoh.configurations.context.DefaultContext;
import org.ytoh.configurations.context.DefaultPublishingContext;
import org.ytoh.configurations.context.PublishingContext;
import org.ytoh.configurations.ui.*;
import javax.validation.Configuration;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.bootstrap.GenericBootstrap;
import java.awt.*;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
import java.util.List;
/**
* Utility class able to retrieve component properties.
* <p/>
* <p>It looks for fields annotation with special annotations that mark object
* fields as properties. This implementations caches the object properties.</p>
*
* @author ytoh
* @see PropertyExtractor#propertiesFor(java.lang.Object)
*/
public class AnnotationPropertyExtractor implements PropertyExtractor {
static final Logger logger = Logger.getLogger(AnnotationPropertyExtractor.class);
/**
* inernal cache
*/
private static final Map<Object, List<Property>> cache;
/**
* type-specific default PropertyEditor repository
*/
private final Map<Class<?>, Class<? extends PropertyEditor>> defaultEditors;
/**
* type-specific default PropertyRenderer repository
*/
private final Map<Class<?>, Class<? extends PropertyRenderer>> defaultRenderers;
ContextAwareConstraintValidatorFactory constraintValidatorFactory = new ContextAwareConstraintValidatorFactory(new ConstraintValidatorFactoryImpl());
/**
* validator to validate input values
*/
private final Validator validator;
/**
* context for dynamic property editation
*/
private final PublishingContext context;
/**
* flag signaling if changes should be sandboxed or not
*/
private final boolean shouldSandbox;
static {
cache = new HashMap<Object, List<Property>>();
}
/**
* Creates a default <code>AnnotationPropertyExtractor</code> instance
* with an empty dynamic context.
*/
public AnnotationPropertyExtractor() {
this(new DefaultPublishingContext(new DefaultContext()), false);
}
public AnnotationPropertyExtractor(PublishingContext context) {
this(context, false);
}
/**
* Creates a <code>AnnotationPropertyExtractor</code> instance with
* the supplied dynamic context.
*
* @param context {@link Context} to be used with this <code>PropertyExtractor</code>
*/
public AnnotationPropertyExtractor(PublishingContext context, boolean shouldSandbox) {
this.context = context;
this.shouldSandbox = shouldSandbox;
this.constraintValidatorFactory.registerContext(context);
GenericBootstrap byDefaultProvider = Validation.byDefaultProvider();
Configuration<?> configuration = ((Configuration<?>) byDefaultProvider.configure()).constraintValidatorFactory(constraintValidatorFactory);
this.validator = configuration.buildValidatorFactory().getValidator();
Map<Class<?>, Class<? extends PropertyEditor>> editors = new HashMap<Class<?>, Class<? extends PropertyEditor>>();
editors.put(Boolean.class, CheckBoxEditor.class);
editors.put(Boolean.TYPE, CheckBoxEditor.class);
editors.put(String.class, TextFieldEditor.class);
editors.put(Integer.TYPE, IntegerTextFieldEditor.class);
editors.put(Double.TYPE, DoubleTextFieldEditor.class);
defaultEditors = Collections.unmodifiableMap(editors);
Map<Class<?>, Class<? extends PropertyRenderer>> renderers = new HashMap<Class<?>, Class<? extends PropertyRenderer>>();
renderers.put(Double.TYPE, DoubleLabel.class);
defaultRenderers = Collections.unmodifiableMap(renderers);
}
/**
* <p>Extracts fields annotated with {@link org.ytoh.configurations.annotations.Property}
* and their defined {@link Editor}s and {@link Renderer}s into instances of
* {@link Property}.</p>
*
* @param o object to extract properties from
* @return a list of <code>Property</code> instances representing properties
* defined on the supplied object
* @throws ConfigurationException if more then one <code>Editor</code> or
* <code>Renderer</code> is defined for any property or if a problem was
* encountered during their instantiation.
*/
public List<Property> propertiesFor(Object o) {
try {
List<Property> cached = cache.get(o);
return cached != null ? cached : extractProperty(o);
} catch (InvocationTargetException ex) {
throw new ConfigurationException("Could not extract properties. ", ex);
} catch (NoSuchMethodException ex) {
throw new ConfigurationException("Could not extract properties.", ex);
} catch (InstantiationException ex) {
throw new ConfigurationException("Could not extract properties.", ex);
} catch (IllegalAccessException ex) {
throw new ConfigurationException("Could not extract properties.", ex);
}
}
/**
* Extracts declared fields from the given class while minding class inheritance.
*
* @param c class to extract fields from
* @return a list of fields
*/
private List<Field> extractDeclaredFields(Class c) {
List<Field> fields = new ArrayList<Field>();
while (!Object.class.equals(c)) {
fields.addAll(Arrays.asList(c.getDeclaredFields()));
c = c.getSuperclass();
}
return fields;
}
/**
* @param o object to extract properties from
* @return a list of properties for the supplied object
* @throws java.lang.InstantiationException
* in case of problems instantiating property editors or renderers
* @throws java.lang.IllegalAccessException
* in case of problems instantiating property editors or renderers
* @see PropertyExtractor#propertiesFor(java.lang.Object)
*/
private List<Property> extractProperty(final Object o) throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
final Object sandbox = shouldSandbox ? o.getClass().newInstance() : o;
Sandbox sandboxContext = Sandbox.newInstance(sandbox);
// properties extraction
List<Field> fields = extractDeclaredFields(o.getClass());
List<MutableProperty> properties = new ArrayList<MutableProperty>();
for (Field field : fields) {
if (isProperty(field)) {
MutableProperty property = null;
if (isComponentProperty(field.getType())) {
property = new DefaultComponentProperty(field.getAnnotation(org.ytoh.configurations.annotations.Property.class), field, sandbox, validator);
} else if (isArrayProperty(field.getType())) {
property = new DefaultArrayProperty(field.getAnnotation(org.ytoh.configurations.annotations.Property.class), field, sandbox, validator);
} else if (isListProperty(field.getType())) {
property = new DefaultListProperty(field.getAnnotation(org.ytoh.configurations.annotations.Property.class), field, sandbox, validator);
} else if (isMappedProperty(field.getType())) {
property = new DefaultMapProperty(field.getAnnotation(org.ytoh.configurations.annotations.Property.class), field, sandbox, validator);
} else {
property = new DefaultProperty(field.getAnnotation(org.ytoh.configurations.annotations.Property.class), field, sandbox, validator);
}
Class fieldType = property.getFieldType();
Annotation editor = getPropertyEditor(field);
Annotation renderer = getPropertyRenderer(field);
PropertyEditor<Object, Annotation> propertyEditor = (editor == null ? getDefaultEditor(fieldType) : editor.annotationType().getAnnotation(Editor.class).component().newInstance());
PropertyRenderer<Object, Annotation> propertyRenderer = (renderer == null ? getDefaultRenderer(fieldType) : renderer.annotationType().getAnnotation(Renderer.class).component().newInstance());
property.setEditor(propertyEditor, editor, this.context);
// property renderers
property.setRenderer(propertyRenderer, renderer);
// set sandbox to mirror the original
if (shouldSandbox) {
PropertyUtils.setProperty(sandbox, property.getFieldName(), PropertyUtils.getProperty(o, property.getFieldName()));
}
property.setValue(PropertyUtils.getProperty(sandbox, property.getFieldName()));
String fieldName = property.getFieldName();
// workaround: PropertyUtils does not property detect the state property for single letter properties
if (fieldName.length() == 1) {
fieldName = fieldName.toUpperCase();
}
if (PropertyUtils.isReadable(sandbox, fieldName + "State")) {
property.setPropertyState((PropertyState) PropertyUtils.getProperty(sandbox, fieldName + "State"));
}
// add the property to a specific context
// hack: for now it is an Abstract property
sandboxContext.addProperty((AbstractProperty) property);
properties.add(property);
}
}
return new ArrayList<Property>(properties);
}
/**
* <p>Instantiates and returns a default {@link Component} used to edit
* the given property based on the type of the underlying field.</p>
* <p>If no type-specific {@link PropertyEditor} is registered as
* the default editor specified a NullObject <code>PropertyEditor</code>
* is used.</p>
*
* @param type of the underlying field
* @return an instance of a <code>Component</code> to be used to edit
* the supplied property
* @throws IllegalAccessException if the default editor component cannot be instantiated.
* @throws InstantiationException if the default editor component cannot be instantiated.
* @see PropertyExtractor #DEFAULT_EDITORS
*/
private <T, A extends Annotation> PropertyEditor<T, A> getDefaultEditor(Class<?> type) throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
Class<? extends PropertyEditor> editor = defaultEditors.get(type);
if (editor == null) {
if (isComponentProperty(type)) {
return (PropertyEditor<T, A>) new PropertyTableEditor();
} else if (type.isEnum()) {
return (PropertyEditor<T, A>) new DropDownEditor(type.getEnumConstants());
}
} else {
return editor.newInstance();
}
// NullObject design pattern
return new DefaultEditor();
}
/**
* <p>Instantiates and returns a default {@link Component} used to render
* the given property based on the type of the underlying field.</p>
* <p>If no type-specific {@link PropertyRenderer} is registered as
* the default renderer specified a NullObject <code>PropertyRenderer</code>
* is used.</p>
*
* @param type of the underlying field
* @return an instance of a <code>Component</code> to be used to render
* the supplied property
* @throws IllegalAccessException if the default renderer component cannot be instantiated.
* @throws InstantiationException if the default renderer component cannot be instantiated.
* @see PropertyExtractor #DEFAULT_RENDERERS
*/
private PropertyRenderer getDefaultRenderer(Class<?> type) throws InstantiationException, IllegalAccessException {
Class<? extends PropertyRenderer> renderer = defaultRenderers.get(type);
// NullObject design pattern
return renderer != null ? renderer.newInstance() : new DefaultRenderer();
}
/**
* <p>Indicates whether or not the given field is a property.</p>
*
* @param field
* @return <code>true</code> if the field is a property
* (annotated with {@link org.ytoh.configurations.annotations.Property})
*/
public boolean isProperty(Field field) {
return CollectionUtils.exists(Arrays.asList(field.getDeclaredAnnotations()), new Predicate() {
public boolean evaluate(Object o) {
return ((Annotation) o) instanceof org.ytoh.configurations.annotations.Property;
}
});
}
/**
* <p>Retrieves custom editor annotations (if any) for the given
* <code>field</code>.</p>
* <p>Properties can have at most one {@link PropertyEditor}.</p>
*
* @param field to analyze
* @return custom editor annotation
* @throws ConfigurationException if the suppied field is annotated with
* more then one custom editor annotations
*/
private Annotation getPropertyEditor(Field field) {
List<Annotation> editors = Annotations.like(Editor.class, field);
if (editors.size() > 1) {
throw new ConfigurationException("Properties can have only one editor (found " + editors.size() + ": " + editors + ") for field: " + field.getName());
}
return editors.isEmpty() ? null : editors.get(0);
}
/**
* <p>Retrieves custom renderer annotations (if any) for the given
* <code>field</code>.</p>
* <p>Properties can have at most one {@link PropertyRenderer}.</p>
*
* @param field to analyze
* @return custom renderer annotation
* @throws ConfigurationException if the suppied field is annotated with
* more then one custom renderer annotations
*/
private Annotation getPropertyRenderer(Field field) {
List<Annotation> renderers = Annotations.like(Renderer.class, field);
if (renderers.size() > 1) {
throw new ConfigurationException("Properties can have only one renderer (found " + renderers.size() + ": " + renderers + ") for field: " + field.getName());
}
return renderers.isEmpty() ? null : renderers.get(0);
}
/**
* Checks if this <code>Class</code> type is a list.
*
* @param type <code>Class</code> type to check
* @return <code>true</code> if it is a list, <code>false</code> otherwise
*/
private boolean isListProperty(Class<?> type) {
return List.class.isAssignableFrom(type);
}
/**
* Checks if this <code>Class</code> type is an array.
*
* @param type <code>Class</code> type to check
* @return <code>true</code> if it is an array, <code>false</code> otherwise
*/
private boolean isArrayProperty(Class<?> type) {
return type.isArray();
}
/**
* Checks if this <code>Class</code> type is a map.
*
* @param type <code>Class</code> type to check
* @return <code>true</code> if it is a map, <code>false</code> otherwise
*/
private boolean isMappedProperty(Class<?> type) {
return Map.class.isAssignableFrom(type);
}
/**
* Checks if this <code>Class</code> type is a {@link org.ytoh.configurations.annotations.Component}.
*
* @param type <code>Class</code> type to check
* @return <code>true</code> if it is a Component, <code>false</code> otherwise
*/
private boolean isComponentProperty(Class<?> type) {
return type.isAnnotationPresent(org.ytoh.configurations.annotations.Component.class);
}
}