package frostillicus.xsp.model; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TreeSet; import javax.faces.application.FacesMessage; import javax.faces.context.FacesContext; import javax.faces.model.DataModel; import javax.persistence.Column; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; import javax.validation.MessageInterpolator; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.metadata.BeanDescriptor; import javax.validation.metadata.ConstraintDescriptor; import javax.validation.metadata.PropertyDescriptor; import org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator; import com.ibm.xsp.designer.context.XSPContext; import com.ibm.xsp.model.ViewRowData; import frostillicus.xsp.util.FrameworkUtils; /** * @since 1.0 */ public abstract class AbstractModelObject extends DataModel implements ModelObject { private static final long serialVersionUID = 1L; private transient Map<String, Method> getterCache_; private transient Map<String, List<Method>> setterCache_; private boolean frozen_; /* ********************************************************************** * Hooks and utility methods for concrete classes * These are named without "get" to avoid steeping on doc fields' toes ************************************************************************/ protected boolean querySave() { return true; } protected void postSave() { } protected boolean queryDelete() { return true; } protected void postDelete() { } @Override public Set<String> propertyNames(final boolean includeSystem, final boolean includeAll) { Set<String> result = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER); for(Field field : getClass().getDeclaredFields()) { if(!Modifier.isStatic(field.getModifiers()) && !field.getName().endsWith("_")) { result.add(field.getName()); } } return result; } @Override public boolean save() { if(readonly()) { return true; } // Time for validation! // We'll be getting values up to twice, so do a bit of cache Map<String, Object> valCache = new HashMap<String, Object>(); // First, check that the data types of all @Columns match boolean invalidSetters = false; for(Field field : getClass().getDeclaredFields()) { if(field.getAnnotation(Column.class) != null) { Object val; if(!valCache.containsKey(field.getName())) { valCache.put(field.getName(), getValue(field.getName())); } val = valCache.get(field.getName()); boolean valid = checkSetter(field, val); if(!valid) { FrameworkUtils.addMessage(FacesMessage.SEVERITY_ERROR, "Field '" + field.getName() + "' is of invalid type " + val.getClass().getName(), null); invalidSetters = true; continue; } } } if(invalidSetters) { return false; } // Now, build a validator for the class Validator validator = Validation.byDefaultProvider().configure() .messageInterpolator(new XSPLocaleResourceBundleMessageInterpolator()) .buildValidatorFactory().getValidator(); // Run through the constrained fields to populate their values from the model object BeanDescriptor desc = validator.getConstraintsForClass(this.getClass()); for(PropertyDescriptor prop : desc.getConstrainedProperties()) { try { Field field = getClass().getDeclaredField(prop.getPropertyName()); Object val; if(!valCache.containsKey(field.getName())) { valCache.put(field.getName(), getValue(field.getName())); } val = valCache.get(field.getName()); field.setAccessible(true); field.set(this, val); } catch (IllegalArgumentException e) { throw new RuntimeException(e); } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (SecurityException e) { throw new RuntimeException(e); } catch (NoSuchFieldException e) { throw new RuntimeException(e); } } // Now run the constraint tests and publish any failures Set<ConstraintViolation<AbstractModelObject>> constraintViolations = validator.validate(this); if(!constraintViolations.isEmpty()) { if(FrameworkUtils.isFaces()) { // In a Faces environment, report the problems to the UI // TODO decide if this is a good idea for(ConstraintViolation<AbstractModelObject> violation : constraintViolations) { FrameworkUtils.addMessage(FacesMessage.SEVERITY_ERROR, violation.getPropertyPath() + ": " + violation.getMessage(), null); } return false; } else { // Otherwise, throw an outright exception, casting the Set to appease the compiler @SuppressWarnings("unchecked") Set<ConstraintViolation<?>> castedViolations = (Set<ConstraintViolation<?>>)(Set<?>)constraintViolations; throw new ConstraintViolationException(castedViolations); } } return true; } @SuppressWarnings({ "unchecked", "rawtypes" }) private boolean checkSetter(final Field field, final Object val) { Class<?> fieldClass = field.getType(); if(val != null) { // See if it's an invalid value // It may be an enum if(fieldClass.isEnum()) { if(val instanceof String) { try { Enum.valueOf((Class<? extends Enum>)fieldClass, (String)val); } catch(IllegalArgumentException e) { return false; } } else { return false; } } else if(!fieldClass.isAssignableFrom(val.getClass())) { return false; } } return true; } @Override public boolean readonly() { return category() || frozen_; } @Override public void freeze() { frozen_ = true; } @Override public void unfreeze() { frozen_ = false; } @Override public boolean frozen() { return frozen_; } @Override public final boolean isNew() { return getId().isEmpty(); } @Override public Type getGenericType(final Object keyObject) { if (!(keyObject instanceof String)) { throw new IllegalArgumentException(); } String key = ((String) keyObject).toLowerCase(); Method getter = findGetter(key); if(getter != null) { return getter.getGenericReturnType(); } else { // Look for the property in the classes declared fields, case-insensitive for(Field field : getClass().getDeclaredFields()) { if(field.getName().equalsIgnoreCase(key)) { return field.getGenericType(); } } // If we're here, there's no definition return Object.class; } } @Override public Set<ConstraintDescriptor<?>> getConstraintDescriptors(final Object keyObj) { String key = String.valueOf(keyObj); final Validator validator = Validation.byDefaultProvider().configure().buildValidatorFactory().getValidator(); BeanDescriptor beanDesc = validator.getConstraintsForClass(getClass()); for(PropertyDescriptor prop : beanDesc.getConstrainedProperties()) { if(prop.getPropertyName().equalsIgnoreCase(key)) { return prop.getConstraintDescriptors(); } } return new HashSet<ConstraintDescriptor<?>>(); } @Override public Field getField(final Object keyObj) { String key = String.valueOf(keyObj); for(Field field : getClass().getDeclaredFields()) { if(field.getName().equalsIgnoreCase(key)) { return field; } } return null; } /* ********************************************************************** * DataModel methods ************************************************************************/ @Override public int getRowCount() { return 1; } @Override public Object getRowData() { return this; } @Override public int getRowIndex() { return 0; } @Override public Object getWrappedData() { return this; } @Override public boolean isRowAvailable() { return true; } @Override public void setRowIndex(final int paramInt) { // NOP } @Override public void setWrappedData(final Object paramObject) { // NOP } @Override public Class<?> getType(final Object keyObject) { if (!(keyObject instanceof String)) { throw new IllegalArgumentException(); } String key = ((String) keyObject).toLowerCase(); Method getter = findGetter(key); if(getter != null) { return getter.getReturnType(); } else { // Look for the property in the classes declared fields, case-insensitive for(Field field : getClass().getDeclaredFields()) { if(field.getName().equalsIgnoreCase(key)) { return field.getType(); } } // If we're here, there's no definition return Object.class; } } @Override public boolean isReadOnly(final Object keyObject) { if(readonly()) { return true; } if (category()) { return true; } if (!(keyObject instanceof String)) { throw new IllegalArgumentException(); } String key = (String) keyObject; if ("id".equalsIgnoreCase(key)) { return true; } else if (findGetter(key) != null && findSetters(key).size() == 0) { // Consider a property with a getter but no setters as read-only return true; } return false; } @Override public Object getValue(final Object keyObject) { if (!(keyObject instanceof String)) { throw new IllegalArgumentException(); } String key = ((String) keyObject).toLowerCase(); // First priority: id if ("id".equalsIgnoreCase(key)) { return getId(); } // Second priority: getters Method getter = findGetter(key); if (getter != null) { try { return getter.invoke(this); } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (InvocationTargetException e) { throw new RuntimeException("InvocationTargetException when asking for '" + keyObject + "' on an object of class " + getClass().getName(), e.getCause()); } } return getValueImmediate(keyObject); } @Override public void setValue(final Object keyObject, final Object value) { if (category()) { throw new UnsupportedOperationException("Categories cannot be modified"); } if (!(keyObject instanceof String)) { throw new IllegalArgumentException(); } String key = ((String) keyObject).toLowerCase(); // First priority: disallow read-only values if (isReadOnly(keyObject)) { throw new IllegalArgumentException(key + " is read-only"); } // Second priority: setters // Look for appropriately-named setters with a fitting type List<Method> setters = findSetters(key); for (Method method : setters) { try { Class<?> param = method.getParameterTypes()[0]; if (value == null || param.isInstance(value)) { method.invoke(this, value); return; } } catch (IllegalArgumentException e) { throw new RuntimeException(e); } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (InvocationTargetException e) { throw new RuntimeException(e); } } // If we reached here with a matching setter name but no matching type, consider it an illegal argument if (setters.size() > 0) { throw new IllegalArgumentException("No match found for setter '" + key + "' with type '" + value.getClass().getName() + "'"); } setValueImmediate(keyObject, value); } protected abstract Object getValueImmediate(Object keyObject); protected abstract void setValueImmediate(Object keyObject, Object value); @SuppressWarnings({ "rawtypes", "unchecked" }) protected Object coaxValue(final String keyObject, final Object value) { Class<?> type = getType(keyObject); if(type != null) { if(type.isEnum() && value != null && !"".equals(value)) { return Enum.valueOf((Class<? extends Enum>)type, String.valueOf(value)); } else if(Boolean.class.equals(type) || Boolean.TYPE.equals(type)) { if(value != null) { if(value.equals(1) || value.equals("Y") || value.equals("true")) { return true; } else if(value.equals(0) || value.equals("N") || value.equals("false")) { return false; } } // For un-set values, boolean gets false, while java.lang.Boolean gets null if(Boolean.TYPE.equals(type)) { return false; } else { return null; } } else if(List.class.isAssignableFrom(type)) { if(value == null) { return new ArrayList<Object>(); } else if(!List.class.isAssignableFrom(value.getClass())) { return new ArrayList<Object>(Arrays.asList(value)); } } else if(value instanceof Date && java.sql.Date.class.equals(type)) { // Then the value should be a java.util.Date return new java.sql.Date(((Date)value).getTime()); } else if(value instanceof Date && java.sql.Time.class.equals(type)) { // Then the value should be a java.util.Date return new java.sql.Time(((Date)value).getTime()); } } return value; } /* ********************************************************************** * ViewRowData methods ************************************************************************/ @Override public final Object getColumnValue(final String key) { return getValue(key); } @Override public final void setColumnValue(final String key, final Object value) { setValue(key, value); } @Override public final ViewRowData.ColumnInfo getColumnInfo(final String key) { return null; } @Override public final boolean isReadOnly(final String key) { return isReadOnly((Object) key); } @Override public final Object getValue(final String key) { return getValue((Object) key); } @Override public final String getOpenPageURL(final String pageName, final boolean readOnly) { if (category()) { return ""; } if(pageName == null) { return ""; } return pageName + (pageName.contains("?") ? "&" : "?") + "id=" + getId(); } /* ********************************************************************** * Reflection seeker methods ************************************************************************/ protected final Method findGetter(final String key) { String lkey = key.toLowerCase(); if (!getterCache().containsKey(lkey)) { Method result = null; for (Method method : getClass().getMethods()) { String methodName = method.getName().toLowerCase(); if (method.getParameterTypes().length == 0 && (methodName.equals("get" + lkey) || methodName.equals("is" + lkey))) { try { result = method; break; } catch (IllegalArgumentException e) { throw new RuntimeException(e); } } } getterCache().put(lkey, result); } return getterCache().get(lkey); } protected final List<Method> findSetters(final String key) { String lkey = key.toLowerCase(); if (!setterCache().containsKey(lkey)) { List<Method> result = new ArrayList<Method>(); for (Method method : getClass().getMethods()) { Class<?>[] parameters = method.getParameterTypes(); String methodName = method.getName().toLowerCase(); if (parameters.length == 1 && methodName.equals("set" + lkey)) { result.add(method); } } setterCache().put(lkey, result); } return setterCache().get(lkey); } private synchronized Map<String, Method> getterCache() { if(getterCache_ == null) { getterCache_ = Collections.synchronizedMap(new HashMap<String, Method>()); } return getterCache_; } private synchronized Map<String, List<Method>> setterCache() { if(setterCache_ == null) { setterCache_ = Collections.synchronizedMap(new HashMap<String, List<Method>>()); } return setterCache_; } /* ********************************************************************** * Validation support ************************************************************************/ private static class XSPLocaleResourceBundleMessageInterpolator extends ResourceBundleMessageInterpolator { @Override public String interpolate(final String message, final MessageInterpolator.Context context) { Locale locale; if(FrameworkUtils.isFaces()) { locale = XSPContext.getXSPContext(FacesContext.getCurrentInstance()).getLocale(); } else { locale = Locale.getDefault(); } return interpolate(message, context, locale); } } }