diff --git a/spring-binding/src/main/java/org/springframework/binding/expression/Expression.java b/spring-binding/src/main/java/org/springframework/binding/expression/Expression.java index a5608420..f615b32e 100644 --- a/spring-binding/src/main/java/org/springframework/binding/expression/Expression.java +++ b/spring-binding/src/main/java/org/springframework/binding/expression/Expression.java @@ -47,8 +47,9 @@ public interface Expression extends Serializable { * context. * @param context the context to evaluate * @return the most general type of value that can be set on this context + * @throws EvaluationException an exception occurred during expression evaluation */ - public Class getValueType(Object context); + public Class getValueType(Object context) throws EvaluationException; /** * Returns the original string used to create this expression, unmodified. diff --git a/spring-webflow/src/main/java/org/springframework/webflow/mvc/view/AbstractMvcView.java b/spring-webflow/src/main/java/org/springframework/webflow/mvc/view/AbstractMvcView.java index 5ba392b1..92802f30 100644 --- a/spring-webflow/src/main/java/org/springframework/webflow/mvc/view/AbstractMvcView.java +++ b/spring-webflow/src/main/java/org/springframework/webflow/mvc/view/AbstractMvcView.java @@ -16,19 +16,25 @@ package org.springframework.webflow.mvc.view; import java.io.IOException; +import java.lang.reflect.Array; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.BeanFactory; import org.springframework.binding.convert.ConversionExecutionException; import org.springframework.binding.convert.ConversionExecutor; import org.springframework.binding.expression.EvaluationException; import org.springframework.binding.expression.Expression; import org.springframework.binding.expression.ExpressionParser; +import org.springframework.binding.expression.ParserContext; import org.springframework.binding.expression.support.FluentParserContext; +import org.springframework.binding.expression.support.StaticExpression; import org.springframework.binding.format.Formatter; import org.springframework.binding.format.FormatterRegistry; import org.springframework.binding.format.InvalidFormatException; @@ -63,6 +69,8 @@ import org.springframework.webflow.expression.DefaultExpressionParserFactory; */ public abstract class AbstractMvcView implements View { + private static final Log logger = LogFactory.getLog(AbstractMvcView.class); + private static final MappingResultsCriteria PROPERTY_NOT_FOUND_ERROR = new PropertyNotFoundError(); private static final MappingResultsCriteria MAPPING_ERROR = new MappingError(); @@ -81,6 +89,12 @@ public abstract class AbstractMvcView implements View { private String eventId; + private Set allowedBindFields; + + private String fieldMarkerPrefix = "_"; + + private ConversionExecutor bindingTypeConverter; + /** * Creates a new MVC view. * @param view the Spring MVC view to render @@ -105,6 +119,34 @@ public abstract class AbstractMvcView implements View { */ public void setFormatterRegistry(FormatterRegistry formatterRegistry) { this.formatterRegistry = formatterRegistry; + bindingTypeConverter = new FormatterBackedMappingConversionExecutor(this.formatterRegistry); + } + + /** + * Specify a prefix that can be used for parameters that mark potentially empty fields, having "prefix + field" as + * name. Such a marker parameter is checked by existence: You can send any value for it, for example "visible". This + * is particularly useful for HTML checkboxes and select options. + *
+ * Default is "_", for "_FIELD" parameters (e.g. "_subscribeToNewsletter"). Set this to null if you want to turn off + * the empty field check completely. + *
+ * HTML checkboxes only send a value when they're checked, so it is not possible to detect that a formerly checked + * box has just been unchecked, at least not with standard HTML means. + *
+ * This auto-reset mechanism addresses this deficiency, provided that a marker parameter is sent for each checkbox + * field, like "_subscribeToNewsletter" for a "subscribeToNewsletter" field. As the marker parameter is sent in any + * case, the data binder can detect an empty field and automatically reset its value. + */ + public void setFieldMarkerPrefix(String fieldMarkerPrefix) { + this.fieldMarkerPrefix = fieldMarkerPrefix; + } + + /** + * Register fields that should be allowed for binding. Default is all fields. + * @param allowedBindFields the set of field name strings to allow + */ + public void setAllowedBindFields(Set allowedBindFields) { + this.allowedBindFields = allowedBindFields; } public void render() throws IOException { @@ -236,23 +278,91 @@ public abstract class AbstractMvcView implements View { } private MappingResults bind(Object model) { + if (logger.isDebugEnabled()) { + logger.debug("Setting up view->model mappings"); + } DefaultMapper mapper = new DefaultMapper(); - addDefaultMappings(mapper, requestContext.getRequestParameters(), model); - return mapper.map(requestContext.getRequestParameters(), model); + ParameterMap requestParameters = requestContext.getRequestParameters(); + addDefaultMappings(mapper, requestParameters.asMap().keySet(), model); + return mapper.map(requestParameters, model); } - private void addDefaultMappings(DefaultMapper mapper, ParameterMap requestParameters, Object model) { - for (Iterator it = requestParameters.asMap().keySet().iterator(); it.hasNext();) { - String name = (String) it.next(); - Expression source = new RequestParameterExpression(name); - Expression target = expressionParser.parseExpression(name, new FluentParserContext().evaluate(model - .getClass())); - DefaultMapping mapping = new DefaultMapping(source, target); - mapping.setTypeConverter(new FormatterBackedMappingConversionExecutor(formatterRegistry)); - mapper.addMapping(mapping); + private void addDefaultMappings(DefaultMapper mapper, Set parameterNames, Object model) { + for (Iterator it = parameterNames.iterator(); it.hasNext();) { + String parameterName = (String) it.next(); + if (fieldMarkerPrefix != null && parameterName.startsWith(fieldMarkerPrefix)) { + String field = parameterName.substring(fieldMarkerPrefix.length()); + if (isAllowedField(field)) { + if (!parameterNames.contains(field)) { + addEmptyValueMapping(mapper, field, model); + } + } else { + if (logger.isDebugEnabled()) { + logger.debug("Will not map field marker parameter '" + parameterName + "'"); + } + } + } else { + if (isAllowedField(parameterName)) { + addDefaultMapping(mapper, parameterName, model); + } else { + if (logger.isDebugEnabled()) { + logger.debug("Will not map parameter '" + parameterName + "'"); + } + } + } } } + private boolean isAllowedField(String field) { + if (allowedBindFields == null) { + // always allow + return true; + } else { + return allowedBindFields.contains(field); + } + } + + private void addEmptyValueMapping(DefaultMapper mapper, String field, Object model) { + ParserContext parserContext = new FluentParserContext().evaluate(model.getClass()); + Expression target = expressionParser.parseExpression(field, parserContext); + try { + Class propertyType = target.getValueType(model); + Expression source = new StaticExpression(getEmptyValue(propertyType)); + DefaultMapping mapping = new DefaultMapping(source, target); + if (logger.isDebugEnabled()) { + logger.debug("Adding empty parameter value mapping for field '" + field + "'"); + } + mapper.addMapping(mapping); + } catch (EvaluationException e) { + + } + } + + private Object getEmptyValue(Class fieldType) { + if (fieldType != null && boolean.class.equals(fieldType) || Boolean.class.equals(fieldType)) { + // Special handling of boolean property. + return Boolean.FALSE; + } else if (fieldType != null && fieldType.isArray()) { + // Special handling of array property. + return Array.newInstance(fieldType.getComponentType(), 0); + } else { + // Default value: try null. + return null; + } + } + + private void addDefaultMapping(DefaultMapper mapper, String parameter, Object model) { + Expression source = new RequestParameterExpression(parameter); + ParserContext parserContext = new FluentParserContext().evaluate(model.getClass()); + Expression target = expressionParser.parseExpression(parameter, parserContext); + DefaultMapping mapping = new DefaultMapping(source, target); + mapping.setTypeConverter(bindingTypeConverter); + if (logger.isDebugEnabled()) { + logger.debug("Adding mapping for parameter '" + parameter + "'"); + } + mapper.addMapping(mapping); + } + private boolean hasMappingErrors(MappingResults results) { return results.hasErrorResults() && !onlyPropertyNotFoundErrorsPresent(results); } diff --git a/spring-webflow/src/test/java/org/springframework/webflow/mvc/view/MvcViewTests.java b/spring-webflow/src/test/java/org/springframework/webflow/mvc/view/MvcViewTests.java index 82e12ed7..5fc8f9ee 100644 --- a/spring-webflow/src/test/java/org/springframework/webflow/mvc/view/MvcViewTests.java +++ b/spring-webflow/src/test/java/org/springframework/webflow/mvc/view/MvcViewTests.java @@ -3,6 +3,7 @@ package org.springframework.webflow.mvc.view; import java.security.Principal; import java.util.Calendar; import java.util.Date; +import java.util.HashSet; import java.util.Locale; import java.util.Map; @@ -184,6 +185,84 @@ public class MvcViewTests extends TestCase { assertEquals("foo", bindBean.getBeanProperty().getName()); } + public void testResumeEventModelBindingAllowedFields() throws Exception { + MockRequestContext context = new MockRequestContext(); + context.putRequestParameter("_eventId", "submit"); + context.putRequestParameter("stringProperty", "foo"); + context.putRequestParameter("integerProperty", "5"); + context.putRequestParameter("dateProperty", "2007-01-01"); + context.putRequestParameter("beanProperty.name", "foo"); + BindBean bindBean = new BindBean(); + StaticExpression modelObject = new StaticExpression(bindBean); + modelObject.setExpressionString("bindBean"); + context.getCurrentState().getAttributes().put("model", modelObject); + context.getFlowScope().put("bindBean", bindBean); + context.getMockExternalContext().setNativeContext(new MockServletContext()); + context.getMockExternalContext().setNativeRequest(new MockHttpServletRequest()); + context.getMockExternalContext().setNativeResponse(new MockHttpServletResponse()); + context.getMockFlowExecutionContext().setKey(new MockFlowExecutionKey("c1v1")); + org.springframework.web.servlet.View mvcView = new MockView(); + AbstractMvcView view = new MockMvcView(mvcView, context); + view.setFormatterRegistry(formatterRegistry); + HashSet allowedBindFields = new HashSet(); + allowedBindFields.add("stringProperty"); + view.setAllowedBindFields(allowedBindFields); + view.processUserEvent(); + assertTrue(view.hasFlowEvent()); + assertEquals("submit", view.getFlowEvent().getId()); + assertEquals("foo", bindBean.getStringProperty()); + assertEquals(new Integer(3), bindBean.getIntegerProperty()); + Calendar cal = Calendar.getInstance(); + cal.clear(); + cal.set(Calendar.YEAR, 2008); + assertEquals(cal.getTime(), bindBean.getDateProperty()); + assertEquals(null, bindBean.getBeanProperty().getName()); + } + + public void testResumeEventModelBindingFieldMarker() throws Exception { + MockRequestContext context = new MockRequestContext(); + context.putRequestParameter("_eventId", "submit"); + context.putRequestParameter("_booleanProperty", "whatever"); + BindBean bindBean = new BindBean(); + StaticExpression modelObject = new StaticExpression(bindBean); + modelObject.setExpressionString("bindBean"); + context.getCurrentState().getAttributes().put("model", modelObject); + context.getFlowScope().put("bindBean", bindBean); + context.getMockExternalContext().setNativeContext(new MockServletContext()); + context.getMockExternalContext().setNativeRequest(new MockHttpServletRequest()); + context.getMockExternalContext().setNativeResponse(new MockHttpServletResponse()); + context.getMockFlowExecutionContext().setKey(new MockFlowExecutionKey("c1v1")); + org.springframework.web.servlet.View mvcView = new MockView(); + AbstractMvcView view = new MockMvcView(mvcView, context); + view.setFormatterRegistry(formatterRegistry); + HashSet allowedBindFields = new HashSet(); + allowedBindFields.add("booleanProperty"); + view.setAllowedBindFields(allowedBindFields); + view.processUserEvent(); + assertEquals(false, bindBean.getBooleanProperty()); + } + + public void testResumeEventModelBindingFieldMarkerFieldPresent() throws Exception { + MockRequestContext context = new MockRequestContext(); + context.putRequestParameter("_eventId", "submit"); + context.putRequestParameter("booleanProperty", "true"); + context.putRequestParameter("_booleanProperty", "whatever"); + BindBean bindBean = new BindBean(); + StaticExpression modelObject = new StaticExpression(bindBean); + modelObject.setExpressionString("bindBean"); + context.getCurrentState().getAttributes().put("model", modelObject); + context.getFlowScope().put("bindBean", bindBean); + context.getMockExternalContext().setNativeContext(new MockServletContext()); + context.getMockExternalContext().setNativeRequest(new MockHttpServletRequest()); + context.getMockExternalContext().setNativeResponse(new MockHttpServletResponse()); + context.getMockFlowExecutionContext().setKey(new MockFlowExecutionKey("c1v1")); + org.springframework.web.servlet.View mvcView = new MockView(); + AbstractMvcView view = new MockMvcView(mvcView, context); + view.setFormatterRegistry(formatterRegistry); + view.processUserEvent(); + assertEquals(true, bindBean.getBooleanProperty()); + } + private class MockMvcView extends AbstractMvcView { public MockMvcView(View view, RequestContext context) { @@ -214,6 +293,7 @@ public class MvcViewTests extends TestCase { private String stringProperty; private Integer integerProperty = new Integer(3); private Date dateProperty; + private boolean booleanProperty = true; private NestedBean beanProperty; public BindBean() { @@ -240,6 +320,14 @@ public class MvcViewTests extends TestCase { this.integerProperty = integerProperty; } + public boolean getBooleanProperty() { + return booleanProperty; + } + + public void setBooleanProperty(boolean booleanProperty) { + this.booleanProperty = booleanProperty; + } + public Date getDateProperty() { return dateProperty; }