diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/Binder.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/Binder.java new file mode 100644 index 00000000000..f60dad64d7d --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/Binder.java @@ -0,0 +1,282 @@ +package org.springframework.ui.binding; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Array; +import java.text.ParseException; +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import org.springframework.context.expression.MapAccessor; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.convert.TypeConverter; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.support.DefaultTypeConverter; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.EvaluationException; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionException; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.expression.spel.support.StandardTypeConverter; +import org.springframework.ui.format.Formatter; + +public class Binder { + + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + private T model; + + private Map bindings; + + private Map, Formatter> typeFormatters = new HashMap, Formatter>(); + + private Map> annotationFormatters = new HashMap>(); + + private ExpressionParser expressionParser; + + private TypeConverter typeConverter; + + private boolean optimisticBinding = true; + + private static Formatter defaultFormatter = new Formatter() { + + public Class getFormattedObjectType() { + return String.class; + } + + public String format(Object object, Locale locale) { + if (object == null) { + return ""; + } else { + return object.toString(); + } + } + + public Object parse(String formatted, Locale locale) + throws ParseException { + if (formatted == "") { + return null; + } else { + return formatted; + } + } + }; + + public Binder(T model) { + this.model = model; + bindings = new HashMap(); + expressionParser = new SpelExpressionParser(); + typeConverter = new DefaultTypeConverter(); + } + + public Binding add(BindingConfiguration binding) { + Binding newBinding; + try { + newBinding = new BindingImpl(binding); + } catch (org.springframework.expression.ParseException e) { + throw new IllegalArgumentException(e); + } + bindings.put(binding.getProperty(), newBinding); + return newBinding; + } + + public void add(Formatter formatter, Class propertyType) { + if (propertyType == null) { + propertyType = formatter.getFormattedObjectType(); + } + typeFormatters.put(propertyType, formatter); + } + + public void add(Formatter formatter, Annotation propertyAnnotation) { + annotationFormatters.put(propertyAnnotation, formatter); + } + + public T getModel() { + return model; + } + + public Binding getBinding(String property) { + Binding binding = bindings.get(property); + if (binding == null && optimisticBinding) { + return add(new BindingConfiguration(property, null, false)); + } else { + return binding; + } + } + + public void bind(Map propertyValues) { + for (Map.Entry entry : propertyValues + .entrySet()) { + Binding binding = getBinding(entry.getKey()); + Object value = entry.getValue(); + if (value instanceof String[]) { + binding.setValues((String[])value); + } else if (value instanceof String) { + binding.setValue((String)entry.getValue()); + } else { + throw new IllegalArgumentException("Illegal argument " + value); + } + } + } + + class BindingImpl implements Binding { + + private Expression property; + + private Formatter formatter; + + private boolean required; + + public BindingImpl(BindingConfiguration config) + throws org.springframework.expression.ParseException { + property = expressionParser.parseExpression(config.getProperty()); + formatter = config.getFormatter(); + required = config.isRequired(); + } + + public String getFormattedValue() { + try { + return format(property.getValue(createEvaluationContext())); + } catch (ExpressionException e) { + throw new IllegalArgumentException(e); + } + } + + public void setValue(String formatted) { + Object value = parse(formatted); + assertRequired(value); + setValue(value); + } + + public String format(Object possibleValue) { + Formatter formatter = getFormatter(); + possibleValue = typeConverter.convert(possibleValue, formatter.getFormattedObjectType()); + return formatter.format(possibleValue, LocaleContextHolder.getLocale()); + } + + public boolean isCollection() { + TypeDescriptor type = TypeDescriptor.valueOf(getValueType()); + return type.isCollection() || type.isArray(); + } + + public String[] getFormattedValues() { + Object multiValue; + try { + multiValue = property.getValue(createEvaluationContext()); + } catch (EvaluationException e) { + throw new IllegalStateException(e); + } + if (multiValue == null) { + return EMPTY_STRING_ARRAY; + } + TypeDescriptor type = TypeDescriptor.valueOf(multiValue.getClass()); + String[] formattedValues; + if (type.isCollection()) { + Collection values = ((Collection)multiValue); + formattedValues = (String[]) Array.newInstance(String.class, values.size()); + copy(values, formattedValues); + } else if (type.isArray()) { + formattedValues = (String[]) Array.newInstance(String.class, Array.getLength(multiValue)); + copy((Iterable) multiValue, formattedValues); + } else { + throw new IllegalStateException(); + } + return formattedValues; + } + + public void setValues(String[] formattedValues) { + Object values = Array.newInstance(getFormatter().getFormattedObjectType(), formattedValues.length); + for (int i = 0; i < formattedValues.length; i++) { + Array.set(values, i, parse(formattedValues[i])); + } + setValue(values); + } + + public boolean isRequired() { + return required; + } + + public Messages getMessages() { + return null; + } + + // internal helpers + + private void assertRequired(Object value) { + if (required && value == null) { + throw new IllegalArgumentException("Value required"); + } + } + + private Object parse(String formatted) { + try { + return getFormatter().parse(formatted, LocaleContextHolder.getLocale()); + } catch (ParseException e) { + throw new IllegalArgumentException("Invalid format " + formatted, e); + } + } + + private Formatter getFormatter() { + if (formatter != null) { + return formatter; + } else { + Class type = getValueType(); + Formatter formatter = typeFormatters.get(type); + if (formatter != null) { + return formatter; + } else { + Annotation[] annotations = getAnnotations(); + for (Annotation a : annotations) { + formatter = annotationFormatters.get(a); + if (formatter != null) { + return formatter; + } + } + return defaultFormatter; + } + } + } + + private Class getValueType() { + try { + // TODO Spring EL currently returns null here when value is null - not correct + return property.getValueType(createEvaluationContext()); + } catch (EvaluationException e) { + throw new IllegalStateException(e); + } + } + + private Annotation[] getAnnotations() { + // TODO Spring EL presently gives us no way to get this information + return new Annotation[0]; + } + + private void copy(Iterable values, String[] formattedValues) { + int i = 0; + for (Object value : values) { + formattedValues[i] = format(value); + i++; + } + } + + private void setValue(Object values) { + try { + property.setValue(createEvaluationContext(), values); + } catch (ExpressionException e) { + throw new IllegalArgumentException(e); + } + } + + } + + private EvaluationContext createEvaluationContext() { + StandardEvaluationContext context = new StandardEvaluationContext(); + context.setRootObject(model); + context.addPropertyAccessor(new MapAccessor()); + context.setTypeConverter(new StandardTypeConverter(typeConverter)); + return context; + } +} diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/Binding.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/Binding.java new file mode 100644 index 00000000000..a41172456c2 --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/Binding.java @@ -0,0 +1,27 @@ +package org.springframework.ui.binding; + +public interface Binding { + + // single-value properties + + String getFormattedValue(); + + void setValue(String formatted); + + String format(Object possibleValue); + + // multi-value properties + + boolean isCollection(); + + String[] getFormattedValues(); + + void setValues(String[] formattedValues); + + // validation metadata + + boolean isRequired(); + + Messages getMessages(); + +} \ No newline at end of file diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/BindingConfiguration.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/BindingConfiguration.java new file mode 100644 index 00000000000..c7a9a24f614 --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/BindingConfiguration.java @@ -0,0 +1,31 @@ +package org.springframework.ui.binding; + +import org.springframework.ui.format.Formatter; + +public class BindingConfiguration { + + private String property; + + private Formatter formatter; + + private boolean required; + + public BindingConfiguration(String property, Formatter formatter, boolean required) { + this.property = property; + this.formatter = formatter; + this.required = required; + } + + public String getProperty() { + return property; + } + + public Formatter getFormatter() { + return formatter; + } + + public boolean isRequired() { + return required; + } + +} diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/Message.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/Message.java new file mode 100644 index 00000000000..cfe5e7c2d4f --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/Message.java @@ -0,0 +1,9 @@ +package org.springframework.ui.binding; + +public interface Message { + + String getText(); + + String getSeverity(); + +} diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/MessageCriteria.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/MessageCriteria.java new file mode 100644 index 00000000000..d56ff6154ad --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/MessageCriteria.java @@ -0,0 +1,5 @@ +package org.springframework.ui.binding; + +public interface MessageCriteria { + +} diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/Messages.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/Messages.java new file mode 100644 index 00000000000..42029a5fd1b --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/Messages.java @@ -0,0 +1,15 @@ +package org.springframework.ui.binding; + +import java.util.List; + +public interface Messages { + + int getCount(); + + Severity getMaximumSeverity(); + + List getAll(); + + List getBySeverity(Severity severity); + +} diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/Severity.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/Severity.java new file mode 100644 index 00000000000..4d6f7f393ce --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/Severity.java @@ -0,0 +1,5 @@ +package org.springframework.ui.binding; + +public enum Severity { + INFO, WARNING, ERROR; +} diff --git a/org.springframework.context/src/main/java/org/springframework/ui/format/DateFormatter.java b/org.springframework.context/src/main/java/org/springframework/ui/format/DateFormatter.java new file mode 100644 index 00000000000..ccafcef39ff --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/format/DateFormatter.java @@ -0,0 +1,92 @@ +/* + * Copyright 2004-2009 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.ui.format; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * A formatter for {@link Date} types. + * Allows the configuration of an explicit date pattern and locale. + * @see SimpleDateFormat + * @author Keith Donald + */ +public class DateFormatter implements Formatter { + + private static Log logger = LogFactory.getLog(DateFormatter.class); + + /** + * The default date pattern. + */ + private static final String DEFAULT_PATTERN = "yyyy-MM-dd"; + + private String pattern; + + /** + * Sets the pattern to use to format date values. + * If not specified, the default pattern 'yyyy-MM-dd' is used. + * @param pattern the date formatting pattern + */ + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public Class getFormattedObjectType() { + return Date.class; + } + + public String format(Date date, Locale locale) { + if (date == null) { + return ""; + } + return getDateFormat(locale).format(date); + } + + public Date parse(String formatted, Locale locale) throws ParseException { + if (formatted.length() == 0) { + return null; + } + return getDateFormat(locale).parse(formatted); + } + + // subclassing hookings + + protected DateFormat getDateFormat(Locale locale) { + DateFormat format = DateFormat.getDateInstance(DateFormat.SHORT, locale); + format.setLenient(false); + if (format instanceof SimpleDateFormat) { + String pattern = determinePattern(this.pattern); + ((SimpleDateFormat) format).applyPattern(pattern); + } else { + logger.warn("Unable to apply format pattern '" + pattern + + "'; Returned DateFormat is not a SimpleDateFormat"); + } + return format; + } + + // internal helpers + + private String determinePattern(String pattern) { + return pattern != null ? pattern : DEFAULT_PATTERN; + } + +} \ No newline at end of file diff --git a/org.springframework.context/src/main/java/org/springframework/ui/format/Formatter.java b/org.springframework.context/src/main/java/org/springframework/ui/format/Formatter.java index 012c31e2f63..c20edeb6967 100644 --- a/org.springframework.context/src/main/java/org/springframework/ui/format/Formatter.java +++ b/org.springframework.context/src/main/java/org/springframework/ui/format/Formatter.java @@ -25,6 +25,12 @@ import java.util.Locale; */ public interface Formatter { + /** + * Returns the type of object this formatter can format. + * @return the formatted object type + */ + Class getFormattedObjectType(); + /** * Format the object of type T for display. * @param object the object to format diff --git a/org.springframework.context/src/main/java/org/springframework/ui/format/FormattingConverter.java b/org.springframework.context/src/main/java/org/springframework/ui/format/FormattingConverter.java index ad9282647fd..e196f04e637 100644 --- a/org.springframework.context/src/main/java/org/springframework/ui/format/FormattingConverter.java +++ b/org.springframework.context/src/main/java/org/springframework/ui/format/FormattingConverter.java @@ -26,6 +26,14 @@ class FormattingConverter implements Converter { this.formatter = formatter; } + public Class getSourceType() { + return formatter.getFormattedObjectType(); + } + + public Class getTargetType() { + return String.class; + } + public String convert(T source) { return formatter.format(source, LocaleContextHolder.getLocale()); } diff --git a/org.springframework.context/src/main/java/org/springframework/ui/format/number/CurrencyFormatter.java b/org.springframework.context/src/main/java/org/springframework/ui/format/number/CurrencyFormatter.java index 96a368356ce..73f86b681ae 100644 --- a/org.springframework.context/src/main/java/org/springframework/ui/format/number/CurrencyFormatter.java +++ b/org.springframework.context/src/main/java/org/springframework/ui/format/number/CurrencyFormatter.java @@ -38,6 +38,10 @@ public class CurrencyFormatter implements Formatter { private boolean lenient; + public Class getFormattedObjectType() { + return BigDecimal.class; + } + public String format(BigDecimal decimal, Locale locale) { if (decimal == null) { return ""; @@ -66,5 +70,5 @@ public class CurrencyFormatter implements Formatter { decimal = decimal.setScale(format.getMaximumFractionDigits(), format.getRoundingMode()); return decimal; } - + } \ No newline at end of file diff --git a/org.springframework.context/src/test/java/org/springframework/ui/binding/BinderTests.java b/org.springframework.context/src/test/java/org/springframework/ui/binding/BinderTests.java new file mode 100644 index 00000000000..6ffebb43f88 --- /dev/null +++ b/org.springframework.context/src/test/java/org/springframework/ui/binding/BinderTests.java @@ -0,0 +1,222 @@ +package org.springframework.ui.binding; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.math.BigDecimal; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.junit.Ignore; +import org.junit.Test; +import org.springframework.ui.format.DateFormatter; +import org.springframework.ui.format.number.CurrencyFormatter; + +public class BinderTests { + + @Test + public void bindSingleValuesWithDefaultTypeConverterConversion() { + Binder binder = new Binder(new TestBean()); + Map propertyValues = new HashMap(); + propertyValues.put("string", "test"); + propertyValues.put("integer", "3"); + propertyValues.put("foo", "BAR"); + binder.bind(propertyValues); + assertEquals("test", binder.getModel().getString()); + assertEquals(3, binder.getModel().getInteger()); + assertEquals(FooEnum.BAR, binder.getModel().getFoo()); + } + + // TODO should update error context, not throw exception + @Test(expected=IllegalArgumentException.class) + public void bindSingleValuesWithDefaultTypeCoversionFailures() { + Binder binder = new Binder(new TestBean()); + Map propertyValues = new HashMap(); + propertyValues.put("string", "test"); + propertyValues.put("integer", "bogus"); + propertyValues.put("foo", "bogus"); + binder.bind(propertyValues); + } + + @Test + public void bindSingleValuePropertyFormatterParsing() throws ParseException { + Binder binder = new Binder(new TestBean()); + binder.add(new BindingConfiguration("date", new DateFormatter(), false)); + Map propertyValues = new HashMap(); + propertyValues.put("date", "2009-06-01"); + binder.bind(propertyValues); + assertEquals(new DateFormatter().parse("2009-06-01", Locale.US), binder.getModel().getDate()); + } + + // TODO should update error context, not throw exception + @Test(expected=IllegalArgumentException.class) + public void bindSingleValuePropertyFormatterParseException() { + Binder binder = new Binder(new TestBean()); + binder.add(new BindingConfiguration("date", new DateFormatter(), false)); + Map propertyValues = new HashMap(); + propertyValues.put("date", "bogus"); + binder.bind(propertyValues); + } + + @Test + @Ignore + public void bindSingleValueTypeFormatterParsing() throws ParseException { + Binder binder = new Binder(new TestBean()); + binder.add(new DateFormatter(), Date.class); + Map propertyValues = new HashMap(); + propertyValues.put("date", "2009-06-01"); + // TODO presently fails because Spring EL does not obtain property valueType using property metadata + // instead it relies on value itself being not null + // talk to andy about this + binder.bind(propertyValues); + assertEquals(new DateFormatter().parse("2009-06-01", Locale.US), binder.getModel().getDate()); + } + + @Test + @Ignore + public void bindSingleValueAnnotationFormatterParsing() throws ParseException { + Binder binder = new Binder(new TestBean()); + binder.add(new CurrencyFormatter(), Currency.class); + Map propertyValues = new HashMap(); + propertyValues.put("currency", "$23.56"); + // TODO presently fails because Spring EL does not obtain property valueType using property metadata + // instead it relies on value itself being not null + // talk to andy about this + binder.bind(propertyValues); + assertEquals(new BigDecimal("23.56"), binder.getModel().getCurrency()); + } + + @Test + public void getBindingOptimistic() { + Binder binder = new Binder(new TestBean()); + Binding b = binder.getBinding("integer"); + assertFalse(b.isRequired()); + assertFalse(b.isCollection()); + assertEquals("0", b.getFormattedValue()); + b.setValue("5"); + assertEquals("5", b.getFormattedValue()); + } + + @Test + public void getBindingCustomFormatter() { + Binder binder = new Binder(new TestBean()); + binder.add(new BindingConfiguration("currency", new CurrencyFormatter(), false)); + Binding b = binder.getBinding("currency"); + assertFalse(b.isRequired()); + assertFalse(b.isCollection()); + assertEquals("", b.getFormattedValue()); + b.setValue("$23.56"); + assertEquals("$23.56", b.getFormattedValue()); + } + + // TODO should update error context, not throw exception + @Test(expected=IllegalArgumentException.class) + public void getBindingRequired() { + Binder binder = new Binder(new TestBean()); + binder.add(new BindingConfiguration("string", null, true)); + Binding b = binder.getBinding("string"); + assertTrue(b.isRequired()); + assertFalse(b.isCollection()); + assertEquals("", b.getFormattedValue()); + b.setValue(""); + } + + @Test + public void getBindingMultiValued() { + Binder binder = new Binder(new TestBean()); + Binding b = binder.getBinding("foos"); + assertTrue(b.isCollection()); + assertEquals(0, b.getFormattedValues().length); + b.setValues(new String[] { "BAR", "BAZ", "BOOP" }); + assertEquals(FooEnum.BAR, binder.getModel().getFoos().get(0)); + assertEquals(FooEnum.BAZ, binder.getModel().getFoos().get(1)); + assertEquals(FooEnum.BOOP, binder.getModel().getFoos().get(2)); + String[] values = b.getFormattedValues(); + assertEquals(3, values.length); + assertEquals("BAR", values[0]); + assertEquals("BAZ", values[1]); + assertEquals("BOOP", values[2]); + } + + @Test(expected=IllegalArgumentException.class) + public void getBindingMultiValuedTypeConversionError() { + Binder binder = new Binder(new TestBean()); + Binding b = binder.getBinding("foos"); + assertTrue(b.isCollection()); + assertEquals(0, b.getFormattedValues().length); + b.setValues(new String[] { "BAR", "BOGUS", "BOOP" }); + } + + public static enum FooEnum { + BAR, BAZ, BOOP; + } + + public static class TestBean { + private String string; + private int integer; + private Date date; + private FooEnum foo; + private BigDecimal currency; + private List foos = new ArrayList(); + + public String getString() { + return string; + } + + public void setString(String string) { + this.string = string; + } + + public int getInteger() { + return integer; + } + + public void setInteger(int integer) { + this.integer = integer; + } + + public Date getDate() { + return date; + } + + public void setDate(Date date) { + this.date = date; + } + + public FooEnum getFoo() { + return foo; + } + + public void setFoo(FooEnum foo) { + this.foo = foo; + } + + public BigDecimal getCurrency() { + return currency; + } + + @Currency + public void setCurrency(BigDecimal currency) { + this.currency = currency; + } + + public List getFoos() { + return foos; + } + + public void setFoos(List foos) { + this.foos = foos; + } + + } + + public @interface Currency { + + } +} diff --git a/org.springframework.core/src/main/java/org/springframework/core/convert/support/AbstractCollectionConverter.java b/org.springframework.core/src/main/java/org/springframework/core/convert/support/AbstractCollectionConverter.java index a72dc586d19..431d575d8ee 100644 --- a/org.springframework.core/src/main/java/org/springframework/core/convert/support/AbstractCollectionConverter.java +++ b/org.springframework.core/src/main/java/org/springframework/core/convert/support/AbstractCollectionConverter.java @@ -39,7 +39,12 @@ abstract class AbstractCollectionConverter implements ConversionExecutor { Class sourceElementType = sourceCollectionType.getElementType(); Class targetElementType = targetCollectionType.getElementType(); if (sourceElementType != null && targetElementType != null) { - elementConverter = conversionService.getConversionExecutor(sourceElementType, TypeDescriptor.valueOf(targetElementType)); + ConversionExecutor executor = conversionService.getConversionExecutor(sourceElementType, TypeDescriptor.valueOf(targetElementType)); + if (executor != null) { + elementConverter = executor; + } else { + elementConverter = NoOpConversionExecutor.INSTANCE; + } } else { elementConverter = NoOpConversionExecutor.INSTANCE; }