From 534c5b31dfab033104c12d6469558c245a32e429 Mon Sep 17 00:00:00 2001 From: Marten Deinum Date: Tue, 3 Feb 2015 22:23:47 +0100 Subject: [PATCH] Replace BeanPropertyRowMapper with BeanWrapperRowMapper. Use the BeanWrapper to bind values to beans. Based on the BeanWrapperFieldSetMapper. --- .../excel/mapping/BeanPropertyRowMapper.java | 335 --------------- .../excel/mapping/BeanWrapperRowMapper.java | 396 ++++++++++++++++++ .../item/excel/mapping/PropertyMatches.java | 175 ++++++++ .../excel/BeanPropertyItemReaderTest.java | 4 +- ...ropertyWithStaticHeaderItemReaderTest.java | 7 +- ...est.java => BeanWrapperRowMapperTest.java} | 11 +- 6 files changed, 581 insertions(+), 347 deletions(-) delete mode 100644 spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/BeanPropertyRowMapper.java create mode 100644 spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/BeanWrapperRowMapper.java create mode 100644 spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/PropertyMatches.java rename spring-batch-excel/src/test/java/org/springframework/batch/item/excel/mapping/{BeanPropertyRowMapperTest.java => BeanWrapperRowMapperTest.java} (91%) diff --git a/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/BeanPropertyRowMapper.java b/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/BeanPropertyRowMapper.java deleted file mode 100644 index 497f559..0000000 --- a/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/BeanPropertyRowMapper.java +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright 2006-2014 the original author or authors. - * - * 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 org.springframework.batch.item.excel.mapping; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.springframework.batch.item.excel.RowMapper; -import org.springframework.batch.item.excel.support.rowset.RowSet; -import org.springframework.batch.item.excel.support.rowset.RowSetMetaData; -import org.springframework.beans.*; -import org.springframework.beans.factory.BeanFactory; -import org.springframework.beans.factory.BeanFactoryAware; -import org.springframework.beans.factory.InitializingBean; -import org.springframework.util.Assert; -import org.springframework.util.ReflectionUtils; -import org.springframework.util.StringUtils; - -import java.beans.PropertyDescriptor; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -/** - * {@link RowMapper} implementation that converts a row into a new instance - * of the specified mapped target class. The mapped target class must be a - * top-level class and it must have a default or no-arg constructor. - * - * Column values are mapped based on matching the column name as obtained from row set - * metadata to public setters for the corresponding properties. The names are matched either - * directly or by transforming a name separating the parts with underscores to the same name - * using "camel" case. - * - * Mapping is provided for fields in the target class for many common types, e.g.: - * String, boolean, Boolean, byte, Byte, short, Short, int, Integer, long, Long, - * float, Float, double, Double, BigDecimal, {@code java.util.Date}, etc. - * - * For 'null' values read from the Excel document, we will attempt to call the setter, but in the case of - * Java primitives, this causes a TypeMismatchException. This class can be configured (using the - * primitivesDefaultedForNullValue property) to trap this exception and use the primitives default value. - * Be aware that if you use the values from the generated bean to update the database the primitive value - * will have been set to the primitive's default value instead of null. - * - * Please note that this class is designed to provide convenience rather than high performance. - * For best performance, consider using a custom {@link RowMapper} implementation. - * - * @author Marten Deinum - * @param The type - * @since 0.5.0 - */ -public class BeanPropertyRowMapper implements RowMapper, BeanFactoryAware, InitializingBean { - - /** - * Logger available to subclasses - */ - protected final Log logger = LogFactory.getLog(getClass()); - - /** - * The class we are mapping to - */ - private Class type; - - /** - * The name of the bean we are mapping to - */ - private String name; - - /** - * Whether we're strictly validating - */ - private boolean checkFullyPopulated = false; - - /** - * Whether we're defaulting primitives when mapping a null value - */ - private boolean primitivesDefaultedForNullValue = false; - - /** - * Map of the fields we provide mapping for - */ - private Map mappedFields; - - /** - * Set of bean properties we provide mapping for - */ - private Set mappedProperties; - - private BeanFactory beanFactory; - - /** - * Create a new BeanPropertyRowMapper for bean-style configuration. - * - * @see #setTargetType - * @see #setPrototypeBeanName - * @see #setCheckFullyPopulated - */ - public BeanPropertyRowMapper() { - } - - - /** - * The bean name (id) for an object that can be populated from the field set - * that will be passed into {@link #mapRow(RowSet)}. Typically a - * prototype scoped bean so that a new instance is returned for each field - * set mapped. - * - * Either this property or the type property must be specified, but not - * both. - * - * @param name the name of a prototype bean in the enclosing BeanFactory - */ - public void setPrototypeBeanName(String name) { - this.name = name; - } - - /** - * Public setter for the type of bean to create instead of using a prototype - * bean. An object of this type will be created from its default constructor - * for every call to {@link #mapRow(RowSet)}.
- * - * Either this property or the prototype bean name must be specified, but - * not both. - * - * @param type the type to set - */ - public void setTargetType(Class type) { - this.type = type; - } - - /** - * Initialize the mapping metadata for the given class. - * - * @param mappedClass the mapped class. - */ - protected void initialize(Class mappedClass) { - this.mappedFields = new HashMap(); - this.mappedProperties = new HashSet(); - PropertyDescriptor[] pds = BeanUtils.getPropertyDescriptors(mappedClass); - for (PropertyDescriptor pd : pds) { - if (pd.getWriteMethod() != null) { - this.mappedFields.put(pd.getName().toLowerCase(), pd); - String underscoredName = underscoreName(pd.getName()); - if (!pd.getName().toLowerCase().equals(underscoredName)) { - this.mappedFields.put(underscoredName, pd); - } - this.mappedProperties.add(pd.getName()); - } - } - } - - /** - * Convert a name in camelCase to an underscored name in lower case. - * Any upper case letters are converted to lower case with a preceding underscore. - * - * @param name the string containing original name - * @return the converted name - */ - private String underscoreName(String name) { - if (!StringUtils.hasLength(name)) { - return ""; - } - StringBuilder result = new StringBuilder(); - result.append(name.substring(0, 1).toLowerCase()); - for (int i = 1; i < name.length(); i++) { - String s = name.substring(i, i + 1); - String slc = s.toLowerCase(); - if (!s.equals(slc)) { - result.append("_").append(slc); - } else { - result.append(s); - } - } - return result.toString(); - } - - /** - * Set whether we're strictly validating that all bean properties have been - * mapped from corresponding database fields. Default is {@code false}, - * accepting unpopulated properties in the target bean. - * - * @param checkFullyPopulated true or false - */ - public void setCheckFullyPopulated(boolean checkFullyPopulated) { - this.checkFullyPopulated = checkFullyPopulated; - } - - /** - * Return whether we're strictly validating that all bean properties have been - * mapped from corresponding database fields. - * - * @return true when resulting bean should be checked - */ - public boolean isCheckFullyPopulated() { - return this.checkFullyPopulated; - } - - /** - * Set whether we're defaulting Java primitives in the case of mapping a null value - * from corresponding database fields. - * - * Default is {@code false}, throwing an exception when nulls are mapped to Java primitives. - * - * @param primitivesDefaultedForNullValue should default values be used when read value is {@code null} - */ - public void setPrimitivesDefaultedForNullValue(boolean primitivesDefaultedForNullValue) { - this.primitivesDefaultedForNullValue = primitivesDefaultedForNullValue; - } - - /** - * Return whether we're defaulting Java primitives in the case of mapping a null value - * from corresponding database fields. - * - * @return {@code true} when default values be used when read value is {@code null} - */ - public boolean isPrimitivesDefaultedForNullValue() { - return primitivesDefaultedForNullValue; - } - - /** - * Extract the values for all columns in the current row. - * - * Utilizes public setters and result set metadata. - * - * @see java.sql.ResultSetMetaData - */ - @Override - public T mapRow(RowSet rs) throws Exception { - T mappedObject = getBean(); - BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(mappedObject); - initBeanWrapper(bw); - - RowSetMetaData rsmd = rs.getMetaData(); - int columnCount = rsmd.getColumnCount(); - Set populatedProperties = (isCheckFullyPopulated() ? new HashSet() : null); - - for (int index = 0; index < columnCount; index++) { - String column = rsmd.getColumnName(index); - PropertyDescriptor pd = this.mappedFields.get(column.replaceAll(" ", "").toLowerCase()); - if (pd != null) { - String value = rs.getColumnValue(index); - if (logger.isDebugEnabled()) { - logger.debug("Mapping column '" + column + "' to property '" + - pd.getName() + "' of type " + pd.getPropertyType()); - } - try { - - bw.setPropertyValue(pd.getName(), value); - } catch (TypeMismatchException e) { - if (value == null && primitivesDefaultedForNullValue) { - logger.debug("Intercepted TypeMismatchException for row " + rs.getCurrentRowIndex() + - " on sheet " + rsmd.getSheetName() + " and column '" + column + "' with value " + value + - " when setting property '" + pd.getName() + "' of type " + pd.getPropertyType() + - " on object: " + mappedObject); - } else { - throw e; - } - } - if (populatedProperties != null) { - populatedProperties.add(pd.getName()); - } - } - } - - if (populatedProperties != null && !populatedProperties.equals(this.mappedProperties)) { - throw new IllegalStateException("Given RowSet does not contain all fields " + - "necessary to populate object of class [" + mappedObject.getClass() + "]: " + this.mappedProperties); - } - - return mappedObject; - } - - /** - * Initialize the given BeanWrapper to be used for row mapping. - * To be called for each row. - *

The default implementation is empty. Can be overridden in subclasses. - * - * @param bw the BeanWrapper to initialize - */ - protected void initBeanWrapper(BeanWrapper bw) { - } - - private T getBean() { - if (name != null) { - return (T) beanFactory.getBean(name); - } - try { - return type.newInstance(); - } catch (InstantiationException e) { - ReflectionUtils.handleReflectionException(e); - } catch (IllegalAccessException e) { - ReflectionUtils.handleReflectionException(e); - } - // should not happen - throw new IllegalStateException("Internal error: could not create bean instance for mapping."); - } - - @Override - public void setBeanFactory(BeanFactory beanFactory) throws BeansException { - this.beanFactory = beanFactory; - } - - @Override - public void afterPropertiesSet() throws Exception { - Assert.state(name != null || type != null, "Either name or type must be provided."); - Assert.state(name == null || type == null, "Both name and type cannot be specified together."); - initialize((Class) getBean().getClass()); - } - - /** - * Static factory method to create a new BeanPropertyRowMapper - * (with the mapped class specified only once). - * - * @param targetType the class that each row should be mapped to - * @param the targetType - * @return the newly created instance - */ - public static BeanPropertyRowMapper newInstance(Class targetType) { - BeanPropertyRowMapper newInstance = new BeanPropertyRowMapper(); - newInstance.setTargetType(targetType); - return newInstance; - } -} diff --git a/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/BeanWrapperRowMapper.java b/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/BeanWrapperRowMapper.java new file mode 100644 index 0000000..1b76ac9 --- /dev/null +++ b/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/BeanWrapperRowMapper.java @@ -0,0 +1,396 @@ +package org.springframework.batch.item.excel.mapping; + +import org.springframework.batch.item.excel.RowMapper; +import org.springframework.batch.item.excel.support.rowset.RowSet; +import org.springframework.batch.support.DefaultPropertyEditorRegistrar; +import org.springframework.beans.*; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.validation.BindException; +import org.springframework.validation.DataBinder; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * {@link RowMapper} implementation based on bean property paths. The + * {@link RowSet} to be mapped should have field name meta data corresponding + * to bean property paths in an instance of the desired type. The instance is + * created and initialized either by referring to to a prototype object by bean + * name in the enclosing BeanFactory, or by providing a class to instantiate + * reflectively.
+ *
+ * + * Nested property paths, including indexed properties in maps and collections, + * can be referenced by the {@link RowSet}names. They will be converted to + * nested bean properties inside the prototype. The {@link RowSet} and the + * prototype are thus tightly coupled by the fields that are available and those + * that can be initialized. If some of the nested properties are optional (e.g. + * collection members) they need to be removed by a post processor.
+ *
+ * + * To customize the way that {@link RowSet} values are converted to the + * desired type for injecting into the prototype there are several choices. You + * can inject {@link java.beans.PropertyEditor} instances directly through the + * {@link #setCustomEditors(Map) customEditors} property, or you can override + * the {@link #createBinder(Object)} and {@link #initBinder(DataBinder)} + * methods, or you can provide a custom {@link RowSet} implementation.
+ *
+ * + * Property name matching is "fuzzy" in the sense that it tolerates close + * matches, as long as the match is unique. For instance: + * + *

    + *
  • Quantity = quantity (field names can be capitalised)
  • + *
  • ISIN = isin (acronyms can be lower case bean property names, as per Java + * Beans recommendations)
  • + *
  • DuckPate = duckPate (capitalisation including camel casing)
  • + *
  • ITEM_ID = itemId (capitalisation and replacing word boundary with + * underscore)
  • + *
  • ORDER.CUSTOMER_ID = order.customerId (nested paths are recursively + * checked)
  • + *
+ * + * The algorithm used to match a property name is to start with an exact match + * and then search successively through more distant matches until precisely one + * match is found. If more than one match is found there will be an error. + * + * + * @author Marten Deinum + * @since 0.5.0 + */ +public class BeanWrapperRowMapper extends DefaultPropertyEditorRegistrar implements RowMapper, BeanFactoryAware, InitializingBean { + + private String name; + + private Class type; + + private BeanFactory beanFactory; + + private ConcurrentMap> propertiesMatched = new ConcurrentHashMap>(); + + private int distanceLimit = 5; + + private boolean strict = true; + + /* + * (non-Javadoc) + * + * @see + * org.springframework.beans.factory.BeanFactoryAware#setBeanFactory(org + * .springframework.beans.factory.BeanFactory) + */ + @Override + public void setBeanFactory(BeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + /** + * The maximum difference that can be tolerated in spelling between input + * key names and bean property names. Defaults to 5, but could be set lower + * if the field names match the bean names. + * + * @param distanceLimit the distance limit to set + */ + public void setDistanceLimit(int distanceLimit) { + this.distanceLimit = distanceLimit; + } + + /** + * The bean name (id) for an object that can be populated from the field set + * that will be passed into {@link #mapRow(org.springframework.batch.item.excel.support.rowset.RowSet)}. Typically a + * prototype scoped bean so that a new instance is returned for each field + * set mapped. + * + * Either this property or the type property must be specified, but not + * both. + * + * @param name the name of a prototype bean in the enclosing BeanFactory + */ + public void setPrototypeBeanName(String name) { + this.name = name; + } + + /** + * Public setter for the type of bean to create instead of using a prototype + * bean. An object of this type will be created from its default constructor + * for every call to {@link #mapRow(org.springframework.batch.item.excel.support.rowset.RowSet)}.
+ * + * Either this property or the prototype bean name must be specified, but + * not both. + * + * @param type the type to set + */ + public void setTargetType(Class type) { + this.type = type; + } + + /** + * Check that precisely one of type or prototype bean name is specified. + * + * @throws IllegalStateException if neither is set or both properties are + * set. + * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet() + */ + @Override + public void afterPropertiesSet() throws Exception { + Assert.state(name != null || type != null, "Either name or type must be provided."); + Assert.state(name == null || type == null, "Both name and type cannot be specified together."); + } + + /** + * Map the {@link org.springframework.batch.item.file.transform.FieldSet} to an object retrieved from the enclosing Spring + * context, or to a new instance of the required type if no prototype is + * available. + * + * @throws org.springframework.validation.BindException if there is a type conversion or other error (if + * the {@link org.springframework.validation.DataBinder} from {@link #createBinder(Object)} has errors + * after binding). + * @throws org.springframework.beans.NotWritablePropertyException if the {@link org.springframework.batch.item.file.transform.FieldSet} contains a + * field that cannot be mapped to a bean property. + * @see org.springframework.batch.item.file.mapping.FieldSetMapper#mapFieldSet(org.springframework.batch.item.file.transform.FieldSet) + */ + @Override + public T mapRow(RowSet rs) throws BindException { + T copy = getBean(); + DataBinder binder = createBinder(copy); + binder.bind(new MutablePropertyValues(getBeanProperties(copy, rs.getProperties()))); + if (binder.getBindingResult().hasErrors()) { + throw new BindException(binder.getBindingResult()); + } + return copy; + } + + /** + * Create a binder for the target object. The binder will then be used to + * bind the properties form a field set into the target object. This + * implementation creates a new {@link DataBinder} and calls out to + * {@link #initBinder(DataBinder)} and + * {@link #registerCustomEditors(org.springframework.beans.PropertyEditorRegistry)}. + * + * @param target the object to bind to. + * @return a {@link DataBinder} that can be used to bind properties to the + * target. + */ + protected DataBinder createBinder(Object target) { + DataBinder binder = new DataBinder(target); + binder.setIgnoreUnknownFields(!this.strict); + initBinder(binder); + registerCustomEditors(binder); + return binder; + } + + /** + * Initialize a new binder instance. This hook allows customization of + * binder settings such as the {@link DataBinder#initDirectFieldAccess() + * direct field access}. Called by {@link #createBinder(Object)}. + *

+ * Note that registration of custom property editors can be done in + * {@link #registerCustomEditors(org.springframework.beans.PropertyEditorRegistry)}. + *

+ * + * @param binder new binder instance + * @see #createBinder(Object) + */ + protected void initBinder(DataBinder binder) { + } + + @SuppressWarnings("unchecked") + private T getBean() { + if (name != null) { + return (T) beanFactory.getBean(name); + } + try { + return type.newInstance(); + } catch (InstantiationException e) { + ReflectionUtils.handleReflectionException(e); + } catch (IllegalAccessException e) { + ReflectionUtils.handleReflectionException(e); + } + // should not happen + throw new IllegalStateException("Internal error: could not create bean instance for mapping."); + } + + /** + * @param bean + * @param properties + * @return + */ + private Properties getBeanProperties(Object bean, Properties properties) { + + Class cls = bean.getClass(); + + // Map from field names to property names + DistanceHolder distanceKey = new DistanceHolder(cls, distanceLimit); + if (!propertiesMatched.containsKey(distanceKey)) { + propertiesMatched.putIfAbsent(distanceKey, new ConcurrentHashMap()); + } + Map matches = new HashMap(propertiesMatched.get(distanceKey)); + + @SuppressWarnings({"unchecked", "rawtypes"}) + Set keys = new HashSet(properties.keySet()); + for (String key : keys) { + + if (matches.containsKey(key)) { + switchPropertyNames(properties, key, matches.get(key)); + continue; + } + + String name = findPropertyName(bean, key); + + if (name != null) { + if (matches.containsValue(name)) { + throw new NotWritablePropertyException( + cls, + name, + "Duplicate match with distance <= " + + distanceLimit + + " found for this property in input keys: " + + keys + + ". (Consider reducing the distance limit or changing the input key names to get a closer match.)"); + } + matches.put(key, name); + switchPropertyNames(properties, key, name); + } + } + + propertiesMatched.replace(distanceKey, new ConcurrentHashMap(matches)); + return properties; + } + + private String findPropertyName(Object bean, String key) { + + if (bean == null) { + return null; + } + + Class cls = bean.getClass(); + + int index = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(key); + String prefix; + String suffix; + + // If the property name is nested recurse down through the properties + // looking for a match. + if (index > 0) { + prefix = key.substring(0, index); + suffix = key.substring(index + 1, key.length()); + String nestedName = findPropertyName(bean, prefix); + if (nestedName == null) { + return null; + } + + Object nestedValue = getPropertyValue(bean, nestedName); + String nestedPropertyName = findPropertyName(nestedValue, suffix); + return nestedPropertyName == null ? null : nestedName + "." + nestedPropertyName; + } + + String name = null; + int distance = 0; + index = key.indexOf(PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR); + + if (index > 0) { + prefix = key.substring(0, index); + suffix = key.substring(index); + } else { + prefix = key; + suffix = ""; + } + + while (name == null && distance <= distanceLimit) { + String[] candidates = PropertyMatches.forProperty(prefix, cls, distance).getPossibleMatches(); + // If we find precisely one match, then use that one... + if (candidates.length == 1) { + String candidate = candidates[0]; + if (candidate.equals(prefix)) { // if it's the same don't + // replace it... + name = key; + } else { + name = candidate + suffix; + } + } + distance++; + } + return name; + } + + private Object getPropertyValue(Object bean, String nestedName) { + BeanWrapperImpl wrapper = new BeanWrapperImpl(bean); + wrapper.setAutoGrowNestedPaths(true); + + Object nestedValue = wrapper.getPropertyValue(nestedName); + if (nestedValue == null) { + try { + nestedValue = wrapper.getPropertyType(nestedName).newInstance(); + wrapper.setPropertyValue(nestedName, nestedValue); + } catch (InstantiationException e) { + ReflectionUtils.handleReflectionException(e); + } catch (IllegalAccessException e) { + ReflectionUtils.handleReflectionException(e); + } + } + return nestedValue; + } + + private void switchPropertyNames(Properties properties, String oldName, String newName) { + String value = properties.getProperty(oldName); + properties.remove(oldName); + properties.setProperty(newName, value); + } + + /** + * Public setter for the 'strict' property. If true, then + * {@link #mapRow(RowSet)} will fail if the RowSet contains fields + * that cannot be mapped to the bean. + * + * @param strict fail if non-mappable properties are found + */ + public void setStrict(boolean strict) { + this.strict = strict; + } + + + private static class DistanceHolder { + private final Class cls; + + private final int distance; + + public DistanceHolder(Class cls, int distance) { + this.cls = cls; + this.distance = distance; + + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((cls == null) ? 0 : cls.hashCode()); + result = prime * result + distance; + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + DistanceHolder other = (DistanceHolder) obj; + if (cls == null) { + if (other.cls != null) + return false; + } else if (!cls.equals(other.cls)) + return false; + if (distance != other.distance) + return false; + return true; + } + } +} diff --git a/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/PropertyMatches.java b/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/PropertyMatches.java new file mode 100644 index 0000000..d1e947d --- /dev/null +++ b/spring-batch-excel/src/main/java/org/springframework/batch/item/excel/mapping/PropertyMatches.java @@ -0,0 +1,175 @@ +package org.springframework.batch.item.excel.mapping; + +import org.springframework.beans.BeanUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +import java.beans.PropertyDescriptor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Helper class for calculating bean property matches, according to. + * Used by BeanWrapperImpl to suggest alternatives for an invalid property name.
+ * + * Copied and slightly modified from Spring core, + * + * @author Alef Arendsen + * @author Arjen Poutsma + * @author Juergen Hoeller + * @author Dave Syer + * + * @since 1.0 + * @see #forProperty(String, Class) + */ +final class PropertyMatches { + + //--------------------------------------------------------------------- + // Static section + //--------------------------------------------------------------------- + + /** Default maximum property distance: 2 */ + public static final int DEFAULT_MAX_DISTANCE = 2; + + + /** + * Create PropertyMatches for the given bean property. + * @param propertyName the name of the property to find possible matches for + * @param beanClass the bean class to search for matches + */ + public static PropertyMatches forProperty(String propertyName, Class beanClass) { + return forProperty(propertyName, beanClass, DEFAULT_MAX_DISTANCE); + } + + /** + * Create PropertyMatches for the given bean property. + * @param propertyName the name of the property to find possible matches for + * @param beanClass the bean class to search for matches + * @param maxDistance the maximum property distance allowed for matches + */ + public static PropertyMatches forProperty(String propertyName, Class beanClass, int maxDistance) { + return new PropertyMatches(propertyName, beanClass, maxDistance); + } + + + //--------------------------------------------------------------------- + // Instance section + //--------------------------------------------------------------------- + + private final String propertyName; + + private String[] possibleMatches; + + + /** + * Create a new PropertyMatches instance for the given property. + */ + private PropertyMatches(String propertyName, Class beanClass, int maxDistance) { + this.propertyName = propertyName; + this.possibleMatches = calculateMatches(BeanUtils.getPropertyDescriptors(beanClass), maxDistance); + } + + + /** + * Return the calculated possible matches. + */ + public String[] getPossibleMatches() { + return possibleMatches; + } + + /** + * Build an error message for the given invalid property name, + * indicating the possible property matches. + */ + public String buildErrorMessage() { + StringBuffer buf = new StringBuffer(); + buf.append("Bean property '"); + buf.append(this.propertyName); + buf.append("' is not writable or has an invalid setter method. "); + + if (ObjectUtils.isEmpty(this.possibleMatches)) { + buf.append("Does the parameter type of the setter match the return type of the getter?"); + } + else { + buf.append("Did you mean "); + for (int i = 0; i < this.possibleMatches.length; i++) { + buf.append('\''); + buf.append(this.possibleMatches[i]); + if (i < this.possibleMatches.length - 2) { + buf.append("', "); + } + else if (i == this.possibleMatches.length - 2){ + buf.append("', or "); + } + } + buf.append("'?"); + } + return buf.toString(); + } + + + /** + * Generate possible property alternatives for the given property and + * class. Internally uses the getStringDistance method, which + * in turn uses the Levenshtein algorithm to determine the distance between + * two Strings. + * @param propertyDescriptors the JavaBeans property descriptors to search + * @param maxDistance the maximum distance to accept + */ + private String[] calculateMatches(PropertyDescriptor[] propertyDescriptors, int maxDistance) { + List candidates = new ArrayList(); + for (int i = 0; i < propertyDescriptors.length; i++) { + if (propertyDescriptors[i].getWriteMethod() != null) { + String possibleAlternative = propertyDescriptors[i].getName(); + int distance = calculateStringDistance(this.propertyName, possibleAlternative); + if (distance <= maxDistance) { + candidates.add(possibleAlternative); + } + } + } + Collections.sort(candidates); + return StringUtils.toStringArray(candidates); + } + + /** + * Calculate the distance between the given two Strings + * according to the Levenshtein algorithm. + * @param s1 the first String + * @param s2 the second String + * @return the distance value + */ + private int calculateStringDistance(String s1, String s2) { + if (s1.length() == 0) { + return s2.length(); + } + if (s2.length() == 0) { + return s1.length(); + } + int d[][] = new int[s1.length() + 1][s2.length() + 1]; + + for (int i = 0; i <= s1.length(); i++) { + d[i][0] = i; + } + for (int j = 0; j <= s2.length(); j++) { + d[0][j] = j; + } + + for (int i = 1; i <= s1.length(); i++) { + char s_i = s1.charAt(i - 1); + for (int j = 1; j <= s2.length(); j++) { + int cost; + char t_j = s2.charAt(j - 1); + if (Character.toLowerCase(s_i) == Character.toLowerCase(t_j)) { + cost = 0; + } else { + cost = 1; + } + d[i][j] = Math.min(Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1), + d[i - 1][j - 1] + cost); + } + } + + return d[s1.length()][s2.length()]; + } +} diff --git a/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/BeanPropertyItemReaderTest.java b/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/BeanPropertyItemReaderTest.java index fe2c679..21b1ca9 100644 --- a/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/BeanPropertyItemReaderTest.java +++ b/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/BeanPropertyItemReaderTest.java @@ -4,7 +4,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.batch.item.ExecutionContext; import org.springframework.batch.item.Player; -import org.springframework.batch.item.excel.mapping.BeanPropertyRowMapper; +import org.springframework.batch.item.excel.mapping.BeanWrapperRowMapper; import java.util.ArrayList; import java.util.List; @@ -32,7 +32,7 @@ public class BeanPropertyItemReaderTest { reader = new MockExcelItemReader(sheet); - BeanPropertyRowMapper rowMapper = new BeanPropertyRowMapper(); + BeanWrapperRowMapper rowMapper = new BeanWrapperRowMapper(); rowMapper.setTargetType(Player.class); rowMapper.afterPropertiesSet(); diff --git a/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/BeanPropertyWithStaticHeaderItemReaderTest.java b/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/BeanPropertyWithStaticHeaderItemReaderTest.java index b3c9444..a5d88e8 100644 --- a/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/BeanPropertyWithStaticHeaderItemReaderTest.java +++ b/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/BeanPropertyWithStaticHeaderItemReaderTest.java @@ -4,10 +4,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.batch.item.ExecutionContext; import org.springframework.batch.item.Player; -import org.springframework.batch.item.excel.MockExcelItemReader; -import org.springframework.batch.item.excel.MockSheet; -import org.springframework.batch.item.excel.mapping.BeanPropertyRowMapper; -import org.springframework.batch.item.excel.support.rowset.DefaultRowSet; +import org.springframework.batch.item.excel.mapping.BeanWrapperRowMapper; import org.springframework.batch.item.excel.support.rowset.DefaultRowSetFactory; import org.springframework.batch.item.excel.support.rowset.StaticColumnNameExtractor; @@ -36,7 +33,7 @@ public class BeanPropertyWithStaticHeaderItemReaderTest { reader = new MockExcelItemReader(sheet); - BeanPropertyRowMapper rowMapper = new BeanPropertyRowMapper(); + BeanWrapperRowMapper rowMapper = new BeanWrapperRowMapper(); rowMapper.setTargetType(Player.class); rowMapper.afterPropertiesSet(); diff --git a/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/mapping/BeanPropertyRowMapperTest.java b/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/mapping/BeanWrapperRowMapperTest.java similarity index 91% rename from spring-batch-excel/src/test/java/org/springframework/batch/item/excel/mapping/BeanPropertyRowMapperTest.java rename to spring-batch-excel/src/test/java/org/springframework/batch/item/excel/mapping/BeanWrapperRowMapperTest.java index bb89e8a..2db9469 100644 --- a/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/mapping/BeanPropertyRowMapperTest.java +++ b/spring-batch-excel/src/test/java/org/springframework/batch/item/excel/mapping/BeanWrapperRowMapperTest.java @@ -17,19 +17,20 @@ import java.util.List; import static org.junit.Assert.*; /** - * Created by in329dei on 17-9-2014. + * @author Marten Deinum + * @since 0.5.0 */ -public class BeanPropertyRowMapperTest { +public class BeanWrapperRowMapperTest { @Test(expected = IllegalStateException.class) public void givenNoNameWhenInitCompleteThenIllegalStateShouldOccur() throws Exception { - BeanPropertyRowMapper mapper = new BeanPropertyRowMapper(); + BeanWrapperRowMapper mapper = new BeanWrapperRowMapper(); mapper.afterPropertiesSet(); } @Test public void givenAValidRowWhenMappingThenAValidPlayerShouldBeConstructed() throws Exception { - BeanPropertyRowMapper mapper = new BeanPropertyRowMapper(); + BeanWrapperRowMapper mapper = new BeanWrapperRowMapper(); mapper.setTargetType(Player.class); mapper.afterPropertiesSet(); @@ -59,7 +60,7 @@ public class BeanPropertyRowMapperTest { public void givenAValidRowWhenMappingThenAValidPlayerShouldBeConstructedBasedOnPrototype() throws Exception { ApplicationContext ctx = new AnnotationConfigApplicationContext(TestConfig.class); - BeanPropertyRowMapper mapper = new BeanPropertyRowMapper(); + BeanWrapperRowMapper mapper = new BeanWrapperRowMapper(); mapper.setPrototypeBeanName("player"); mapper.setBeanFactory(ctx); mapper.afterPropertiesSet();