001/*
002 * Copyright 2006-2007 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.batch.item.file.mapping;
018
019import java.beans.PropertyEditor;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.Map;
023import java.util.Properties;
024import java.util.Set;
025import java.util.concurrent.ConcurrentHashMap;
026import java.util.concurrent.ConcurrentMap;
027
028import org.springframework.batch.item.file.transform.FieldSet;
029import org.springframework.batch.support.DefaultPropertyEditorRegistrar;
030import org.springframework.beans.BeanWrapperImpl;
031import org.springframework.beans.MutablePropertyValues;
032import org.springframework.beans.NotWritablePropertyException;
033import org.springframework.beans.PropertyAccessor;
034import org.springframework.beans.PropertyAccessorUtils;
035import org.springframework.beans.PropertyEditorRegistry;
036import org.springframework.beans.factory.BeanFactory;
037import org.springframework.beans.factory.BeanFactoryAware;
038import org.springframework.beans.factory.InitializingBean;
039import org.springframework.beans.factory.config.CustomEditorConfigurer;
040import org.springframework.core.convert.ConversionService;
041import org.springframework.util.Assert;
042import org.springframework.util.ReflectionUtils;
043import org.springframework.validation.BindException;
044import org.springframework.validation.DataBinder;
045
046/**
047 * {@link FieldSetMapper} implementation based on bean property paths. The
048 * {@link FieldSet} to be mapped should have field name meta data corresponding
049 * to bean property paths in an instance of the desired type. The instance is
050 * created and initialized either by referring to a prototype object by bean
051 * name in the enclosing BeanFactory, or by providing a class to instantiate
052 * reflectively.<br>
053 * <br>
054 * 
055 * Nested property paths, including indexed properties in maps and collections,
056 * can be referenced by the {@link FieldSet} names. They will be converted to
057 * nested bean properties inside the prototype. The {@link FieldSet} and the
058 * prototype are thus tightly coupled by the fields that are available and those
059 * that can be initialized. If some of the nested properties are optional (e.g.
060 * collection members) they need to be removed by a post processor.<br>
061 * <br>
062 * 
063 * To customize the way that {@link FieldSet} values are converted to the
064 * desired type for injecting into the prototype there are several choices. You
065 * can inject {@link PropertyEditor} instances directly through the
066 * {@link #setCustomEditors(Map) customEditors} property, or you can override
067 * the {@link #createBinder(Object)} and {@link #initBinder(DataBinder)}
068 * methods, or you can provide a custom {@link FieldSet} implementation.
069 * You can also use a {@link ConversionService} to convert to the desired type
070 * through the {@link #setConversionService(ConversionService) conversionService}
071 * property.
072 * <br>
073 * <br>
074 * 
075 * Property name matching is "fuzzy" in the sense that it tolerates close
076 * matches, as long as the match is unique. For instance:
077 * 
078 * <ul>
079 * <li>Quantity = quantity (field names can be capitalised)</li>
080 * <li>ISIN = isin (acronyms can be lower case bean property names, as per Java
081 * Beans recommendations)</li>
082 * <li>DuckPate = duckPate (capitalisation including camel casing)</li>
083 * <li>ITEM_ID = itemId (capitalisation and replacing word boundary with
084 * underscore)</li>
085 * <li>ORDER.CUSTOMER_ID = order.customerId (nested paths are recursively
086 * checked)</li>
087 * </ul>
088 * 
089 * The algorithm used to match a property name is to start with an exact match
090 * and then search successively through more distant matches until precisely one
091 * match is found. If more than one match is found there will be an error.
092 * 
093 * @author Dave Syer
094 * 
095 */
096public class BeanWrapperFieldSetMapper<T> extends DefaultPropertyEditorRegistrar implements FieldSetMapper<T>,
097                BeanFactoryAware, InitializingBean {
098
099        private String name;
100
101        private Class<? extends T> type;
102
103        private BeanFactory beanFactory;
104
105        private ConcurrentMap<DistanceHolder, ConcurrentMap<String, String>> propertiesMatched = new ConcurrentHashMap<DistanceHolder, ConcurrentMap<String, String>>();
106
107        private int distanceLimit = 5;
108
109        private boolean strict = true;
110
111        private ConversionService conversionService;
112
113        private boolean isCustomEditorsSet;
114
115        /*
116         * (non-Javadoc)
117         * 
118         * @see
119         * org.springframework.beans.factory.BeanFactoryAware#setBeanFactory(org
120         * .springframework.beans.factory.BeanFactory)
121         */
122    @Override
123        public void setBeanFactory(BeanFactory beanFactory) {
124                this.beanFactory = beanFactory;
125        }
126
127        /**
128         * The maximum difference that can be tolerated in spelling between input
129         * key names and bean property names. Defaults to 5, but could be set lower
130         * if the field names match the bean names.
131         * 
132         * @param distanceLimit the distance limit to set
133         */
134        public void setDistanceLimit(int distanceLimit) {
135                this.distanceLimit = distanceLimit;
136        }
137
138        /**
139         * The bean name (id) for an object that can be populated from the field set
140         * that will be passed into {@link #mapFieldSet(FieldSet)}. Typically a
141         * prototype scoped bean so that a new instance is returned for each field
142         * set mapped.
143         * 
144         * Either this property or the type property must be specified, but not
145         * both.
146         * 
147         * @param name the name of a prototype bean in the enclosing BeanFactory
148         */
149        public void setPrototypeBeanName(String name) {
150                this.name = name;
151        }
152
153        /**
154         * Public setter for the type of bean to create instead of using a prototype
155         * bean. An object of this type will be created from its default constructor
156         * for every call to {@link #mapFieldSet(FieldSet)}.<br>
157         * 
158         * Either this property or the prototype bean name must be specified, but
159         * not both.
160         * 
161         * @param type the type to set
162         */
163        public void setTargetType(Class<? extends T> type) {
164                this.type = type;
165        }
166
167        /**
168         * Check that precisely one of type or prototype bean name is specified.
169         * 
170         * @throws IllegalStateException if neither is set or both properties are
171         * set.
172         * 
173         * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet()
174         */
175    @Override
176        public void afterPropertiesSet() throws Exception {
177                Assert.state(name != null || type != null, "Either name or type must be provided.");
178                Assert.state(name == null || type == null, "Both name and type cannot be specified together.");
179                Assert.state(!this.isCustomEditorsSet || this.conversionService == null, "Both customEditor and conversionService cannot be specified together.");
180        }
181
182        /**
183         * Map the {@link FieldSet} to an object retrieved from the enclosing Spring
184         * context, or to a new instance of the required type if no prototype is
185         * available.
186         * @throws BindException if there is a type conversion or other error (if
187         * the {@link DataBinder} from {@link #createBinder(Object)} has errors
188         * after binding).
189         * 
190         * @throws NotWritablePropertyException if the {@link FieldSet} contains a
191         * field that cannot be mapped to a bean property.
192         * @see org.springframework.batch.item.file.mapping.FieldSetMapper#mapFieldSet(FieldSet)
193         */
194    @Override
195        public T mapFieldSet(FieldSet fs) throws BindException {
196                T copy = getBean();
197                DataBinder binder = createBinder(copy);
198                binder.bind(new MutablePropertyValues(getBeanProperties(copy, fs.getProperties())));
199                if (binder.getBindingResult().hasErrors()) {
200                        throw new BindException(binder.getBindingResult());
201                }
202                return copy;
203        }
204
205        /**
206         * Create a binder for the target object. The binder will then be used to
207         * bind the properties form a field set into the target object. This
208         * implementation creates a new {@link DataBinder} and calls out to
209         * {@link #initBinder(DataBinder)} and
210         * {@link #registerCustomEditors(PropertyEditorRegistry)}.
211         * 
212         * @param target Object to bind to
213         * @return a {@link DataBinder} that can be used to bind properties to the
214         * target.
215         */
216        protected DataBinder createBinder(Object target) {
217                DataBinder binder = new DataBinder(target);
218                binder.setIgnoreUnknownFields(!this.strict);
219                initBinder(binder);
220                registerCustomEditors(binder);
221                if(this.conversionService != null) {
222                        binder.setConversionService(this.conversionService);
223                }
224                return binder;
225        }
226
227        /**
228         * Initialize a new binder instance. This hook allows customization of
229         * binder settings such as the {@link DataBinder#initDirectFieldAccess()
230         * direct field access}. Called by {@link #createBinder(Object)}.
231         * <p>
232         * Note that registration of custom property editors can be done in
233         * {@link #registerCustomEditors(PropertyEditorRegistry)}.
234         * </p>
235         * @param binder new binder instance
236         * @see #createBinder(Object)
237         */
238        protected void initBinder(DataBinder binder) {
239        }
240
241        @SuppressWarnings("unchecked")
242        private T getBean() {
243                if (name != null) {
244                        return (T) beanFactory.getBean(name);
245                }
246                try {
247                        return type.newInstance();
248                }
249                catch (InstantiationException | IllegalAccessException e) {
250                        ReflectionUtils.handleReflectionException(e);
251                }
252                // should not happen
253                throw new IllegalStateException("Internal error: could not create bean instance for mapping.");
254        }
255
256        /**
257         * @param bean Object to get properties for
258         * @param properties Properties to retrieve
259         */
260        private Properties getBeanProperties(Object bean, Properties properties) {
261
262                Class<?> cls = bean.getClass();
263
264                // Map from field names to property names
265                DistanceHolder distanceKey = new DistanceHolder(cls, distanceLimit);
266                if (!propertiesMatched.containsKey(distanceKey)) {
267                        propertiesMatched.putIfAbsent(distanceKey, new ConcurrentHashMap<>());
268                }
269                Map<String, String> matches = new HashMap<>(propertiesMatched.get(distanceKey));
270
271                @SuppressWarnings({ "unchecked", "rawtypes" })
272                Set<String> keys = new HashSet(properties.keySet());
273                for (String key : keys) {
274
275                        if (matches.containsKey(key)) {
276                                switchPropertyNames(properties, key, matches.get(key));
277                                continue;
278                        }
279
280                        String name = findPropertyName(bean, key);
281
282                        if (name != null) {
283                                if (matches.containsValue(name)) {
284                                        throw new NotWritablePropertyException(
285                                                        cls,
286                                                        name,
287                                                        "Duplicate match with distance <= "
288                                                                        + distanceLimit
289                                                                        + " found for this property in input keys: "
290                                                                        + keys
291                                                                        + ". (Consider reducing the distance limit or changing the input key names to get a closer match.)");
292                                }
293                                matches.put(key, name);
294                                switchPropertyNames(properties, key, name);
295                        }
296                }
297
298                propertiesMatched.replace(distanceKey, new ConcurrentHashMap<>(matches));
299                return properties;
300        }
301
302        private String findPropertyName(Object bean, String key) {
303
304                if (bean == null) {
305                        return null;
306                }
307
308                Class<?> cls = bean.getClass();
309
310                int index = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(key);
311                String prefix;
312                String suffix;
313
314                // If the property name is nested recurse down through the properties
315                // looking for a match.
316                if (index > 0) {
317                        prefix = key.substring(0, index);
318                        suffix = key.substring(index + 1, key.length());
319                        String nestedName = findPropertyName(bean, prefix);
320                        if (nestedName == null) {
321                                return null;
322                        }
323
324                        Object nestedValue = getPropertyValue(bean, nestedName);
325                        String nestedPropertyName = findPropertyName(nestedValue, suffix);
326                        return nestedPropertyName == null ? null : nestedName + "." + nestedPropertyName;
327                }
328
329                String name = null;
330                int distance = 0;
331                index = key.indexOf(PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR);
332
333                if (index > 0) {
334                        prefix = key.substring(0, index);
335                        suffix = key.substring(index);
336                }
337                else {
338                        prefix = key;
339                        suffix = "";
340                }
341
342                while (name == null && distance <= distanceLimit) {
343                        String[] candidates = PropertyMatches.forProperty(prefix, cls, distance).getPossibleMatches();
344                        // If we find precisely one match, then use that one...
345                        if (candidates.length == 1) {
346                                String candidate = candidates[0];
347                                if (candidate.equals(prefix)) { // if it's the same don't
348                                        // replace it...
349                                        name = key;
350                                }
351                                else {
352                                        name = candidate + suffix;
353                                }
354                        }
355                        distance++;
356                }
357                return name;
358        }
359
360        private Object getPropertyValue(Object bean, String nestedName) {
361                BeanWrapperImpl wrapper = new BeanWrapperImpl(bean);
362                wrapper.setAutoGrowNestedPaths(true);
363
364                Object nestedValue = wrapper.getPropertyValue(nestedName);
365                if (nestedValue == null) {
366                        try {
367                                nestedValue = wrapper.getPropertyType(nestedName).newInstance();
368                                wrapper.setPropertyValue(nestedName, nestedValue);
369                        }
370                        catch (InstantiationException | IllegalAccessException e) {
371                                ReflectionUtils.handleReflectionException(e);
372                        }
373                }
374                return nestedValue;
375        }
376
377        private void switchPropertyNames(Properties properties, String oldName, String newName) {
378                String value = properties.getProperty(oldName);
379                properties.remove(oldName);
380                properties.setProperty(newName, value);
381        }
382
383        /**
384         * Public setter for the 'strict' property. If true, then
385         * {@link #mapFieldSet(FieldSet)} will fail of the FieldSet contains fields
386         * that cannot be mapped to the bean.
387         * 
388         * @param strict indicator
389         */
390        public void setStrict(boolean strict) {
391                this.strict = strict;
392        }
393
394
395        /**
396         * Public setter for the 'conversionService' property.
397         * {@link #createBinder(Object)} will use it if not null.
398         *
399         * @param conversionService {@link ConversionService} to be used for type conversions
400         */
401        public void setConversionService(ConversionService conversionService) {
402                this.conversionService = conversionService;
403        }
404
405        /**
406         * Specify the {@link PropertyEditor custom editors} to register.
407         *
408         *
409         * @param customEditors a map of Class to PropertyEditor (or class name to
410         * PropertyEditor).
411         * @see CustomEditorConfigurer#setCustomEditors(Map)
412         */
413        @Override
414        public void setCustomEditors(Map<? extends Object, ? extends PropertyEditor> customEditors) {
415                this.isCustomEditorsSet = true;
416                super.setCustomEditors(customEditors);
417        }
418
419        private static class DistanceHolder {
420                private final Class<?> cls;
421
422                private final int distance;
423
424                public DistanceHolder(Class<?> cls, int distance) {
425                        this.cls = cls;
426                        this.distance = distance;
427
428                }
429
430                @Override
431                public int hashCode() {
432                        final int prime = 31;
433                        int result = 1;
434                        result = prime * result + ((cls == null) ? 0 : cls.hashCode());
435                        result = prime * result + distance;
436                        return result;
437                }
438
439                @Override
440                public boolean equals(Object obj) {
441                        if (this == obj)
442                                return true;
443                        if (obj == null)
444                                return false;
445                        if (getClass() != obj.getClass())
446                                return false;
447                        DistanceHolder other = (DistanceHolder) obj;
448                        if (cls == null) {
449                                if (other.cls != null)
450                                        return false;
451                        }
452                        else if (!cls.equals(other.cls))
453                                return false;
454                        if (distance != other.distance)
455                                return false;
456                        return true;
457                }
458        }
459
460}