From ccaabe6db025f7e73adc83b4d0e2671c7fa16d40 Mon Sep 17 00:00:00 2001 From: Denis Anisimov Date: Thu, 25 Aug 2016 14:53:09 +0300 Subject: [PATCH] Add item level validator support to Binder An item level validator is run on the item (bean) after field validators have passed. A failed item level validator will block save operations, just like field level validators. Change-Id: I3b918b33371ceef07cdfbd0a8b6d477d4ac26b85 --- .../datamodel/datamodel-forms.asciidoc | 39 +-- .../main/java/com/vaadin/data/BeanBinder.java | 6 + .../src/main/java/com/vaadin/data/Binder.java | 234 ++++++++++++++---- .../java/com/vaadin/data/ValidationError.java | 66 +++-- .../java/com/vaadin/data/BeanBinderTest.java | 10 +- .../vaadin/data/BinderBookOfVaadinTest.java | 97 ++++++-- .../test/java/com/vaadin/data/BinderTest.java | 228 +++++++++++++++++ 7 files changed, 582 insertions(+), 98 deletions(-) diff --git a/documentation/datamodel/datamodel-forms.asciidoc b/documentation/datamodel/datamodel-forms.asciidoc index fc21226358..5ec548e8be 100644 --- a/documentation/datamodel/datamodel-forms.asciidoc +++ b/documentation/datamodel/datamodel-forms.asciidoc @@ -584,30 +584,41 @@ binder.setStatusHandler((List results) -> { }); ---- -[classname]#BeanBinder# will automatically run bean-level validation based on the used bean instance if it has been bound using the [methodname]#bind# method. +We can add custom form validators to [classname]#Binder#. These will be run on the updated item instance (bean) after field validators have succeeded and the item has been updated. If item level validators fail, the values updated in the item instance will be reverted, i.e. the bean will temporarily contain new values but after a call to [methodname]#save# or [methodname]#saveIfValid#, the bean will only contain the new values if all validators passed. -If we are using the [methodname]#load# and [methodname]#save# methods, then the binder will not have any bean instance to use for bean-level validation. -We must use a copy of the bean for running bean-level validation if we want to make sure no changes are done to the original bean before we know that validation passes. +[classname]#BeanBinder# will automatically add bean-level validation based on the used bean instance and its annotations. [source, java] ---- -Button saveButton = new Button("Save", event -> { - // Create non-shared copy to use for validation - Person copy = new Person(person); +BeanBinder binder = new BeanBinder( + Person.class); - List> errors = binder.validateWithBean(copy); - if (errors.isEmpty()) { - // Write new values to the actual bean +// Phone or email has to be specified for the bean +Validator phoneOrEmail = Validator.from( + personBean -> !"".equals(personBean.getPhone()) + || !"".equals(personBean.getEmail()), + "A person must have either a phone number or an email address"); +binder.withValidator(phoneOrEmail); - // Using saveIfValid to avoid the try-catch block that is - // needed if using the regular save method - binder.saveIfValid(person); +binder.forField(emailField).bind("email"); +binder.forField(phoneField).bind("phone"); - // TODO: Do something with the updated person instance +Person person = // e.g. JPA entity or bean from Grid +// Load person data to a form +binder.load(person); + +Button saveButton = new Button("Save", event -> { + // Using saveIfValid to avoid the try-catch block that is + // needed if using the regular save method + if (binder.saveIfValid(person)) { + // Person is valid and updated + // TODO Store in the database } -}) +}); ---- +If we want to ensure that the [classname]#Person# instance is not even temporarily updated, we should make a clone and use that with [methodname]#saveIfValid#. + == Using Binder with Vaadin Designer We can use [classname]#Binder# to connect data to a form that is designed using Vaadin Designer. diff --git a/server/src/main/java/com/vaadin/data/BeanBinder.java b/server/src/main/java/com/vaadin/data/BeanBinder.java index 18985bc90c..63ad9ef04f 100644 --- a/server/src/main/java/com/vaadin/data/BeanBinder.java +++ b/server/src/main/java/com/vaadin/data/BeanBinder.java @@ -310,6 +310,11 @@ public class BeanBinder extends Binder { forField(field).bind(propertyName); } + @Override + public BeanBinder withValidator(Validator validator) { + return (BeanBinder) super.withValidator(validator); + } + @Override protected BeanBindingImpl createBinding( HasValue field, Converter converter, @@ -318,4 +323,5 @@ public class BeanBinder extends Binder { Objects.requireNonNull(converter, "converter cannot be null"); return new BeanBindingImpl<>(this, field, converter, handler); } + } diff --git a/server/src/main/java/com/vaadin/data/Binder.java b/server/src/main/java/com/vaadin/data/Binder.java index 094afd2bd8..7fb1e47ed7 100644 --- a/server/src/main/java/com/vaadin/data/Binder.java +++ b/server/src/main/java/com/vaadin/data/Binder.java @@ -17,15 +17,19 @@ package com.vaadin.data; import java.io.Serializable; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.Collectors; import com.vaadin.data.util.converter.Converter; import com.vaadin.data.util.converter.StringToIntegerConverter; @@ -42,13 +46,25 @@ import com.vaadin.ui.Label; * explicit logic needed to move data between the UI and the data layers of the * application. *

- * A binder is a collection of bindings, each representing the - * association of a single field and a backing property. + * A binder is a collection of bindings, each representing the mapping of + * a single field, through converters and validators, to a backing property. *

* A binder instance can be bound to a single bean instance at a time, but can * be rebound as needed. This allows usage patterns like a master-details * view, where a select component is used to pick the bean to edit. *

+ * Bean level validators can be added using the + * {@link #withValidator(Validator)} method and will be run on the bound bean + * once it has been updated from the values of the bound fields. Bean level + * validators are also run as part of {@link #save(Object)} and + * {@link #saveIfValid(Object)} if all field level validators pass. + *

+ * Note: For bean level validators, the item must be updated before the + * validators are run. If a bean level validator fails in {@link #save(Object)} + * or {@link #saveIfValid(Object)}, the item will be reverted to the previous + * state before returning from the method. You should ensure that the + * getters/setters in the item do not have side effects. + *

* Unless otherwise specified, {@code Binder} method arguments cannot be null. * * @author Vaadin Ltd. @@ -484,14 +500,12 @@ public class Binder implements Serializable { private void bind(BEAN bean) { setFieldValue(bean); onValueChange = getField() - .addValueChangeListener(e -> storeFieldValue(bean)); + .addValueChangeListener(e -> storeFieldValue(bean, true)); } @Override public Result validate() { - FIELDVALUE fieldValue = field.getValue(); - Result dataValue = converterValidatorChain.convertToModel( - fieldValue, ((AbstractComponent) field).getLocale()); + Result dataValue = getTargetValue(); fireStatusChangeEvent(dataValue); return dataValue; } @@ -503,7 +517,10 @@ public class Binder implements Serializable { * describing an error */ private Result getTargetValue() { - return validate(); + FIELDVALUE fieldValue = field.getValue(); + Result dataValue = converterValidatorChain.convertToModel( + fieldValue, ((AbstractComponent) field).getLocale()); + return dataValue; } private void unbind() { @@ -530,18 +547,35 @@ public class Binder implements Serializable { /** * Saves the field value by invoking the setter function on the given - * bean, if the value passes all registered validators. + * bean, if the value passes all registered validators. Optionally runs + * item level validators if all field validators pass. * * @param bean * the bean to set the property value to + * @param runBeanLevelValidation + * true to run item level validators if all + * field validators pass, false to always skip + * item level validators */ - private void storeFieldValue(BEAN bean) { + private void storeFieldValue(BEAN bean, + boolean runBeanLevelValidation) { assert bean != null; if (setter != null) { - getTargetValue().ifOk(value -> setter.accept(bean, value)); + getTargetValue().ifOk(value -> setBeanValue(bean, value)); + } + if (runBeanLevelValidation && !getBinder().bindings.stream() + .map(BindingImpl::getTargetValue) + .anyMatch(Result::isError)) { + List> errors = binder.validateItem(bean); + // TODO: Pass errors to Binder statusChangeHandler once that is + // available } } + private void setBeanValue(BEAN bean, TARGET value) { + setter.accept(bean, value); + } + private void fireStatusChangeEvent(Result result) { ValidationStatusChangeEvent event = new ValidationStatusChangeEvent( getField(), @@ -594,7 +628,9 @@ public class Binder implements Serializable { private BEAN bean; - private Set> bindings = new LinkedHashSet<>(); + private final Set> bindings = new LinkedHashSet<>(); + + private final List> validators = new ArrayList<>(); /** * Returns an {@code Optional} of the bean that has been bound with @@ -683,6 +719,14 @@ public class Binder implements Serializable { * corresponding getter functions. Any changes to field values are reflected * back to their corresponding property values of the bean as long as the * bean is bound. + *

+ * Any change made in the fields also runs validation for the field + * {@link Binding} and bean level validation for this binder (bean level + * validators are added using {@link Binder#withValidator(Validator)}. + * + * @see #load(Object) + * @see #save(Object) + * @see #saveIfValid(Object) * * @param bean * the bean to edit, not null @@ -694,24 +738,6 @@ public class Binder implements Serializable { bindings.forEach(b -> b.bind(bean)); } - /** - * Validates the values of all bound fields and returns the result of the - * validation as a set of validation errors. - *

- * Validation is successful if the resulting set is empty. - * - * @return the validation result. - */ - public List> validate() { - - List> resultErrors = new ArrayList<>(); - for (BindingImpl binding : bindings) { - binding.validate().ifError(errorMessage -> resultErrors.add( - new ValidationError<>(binding.getField(), errorMessage))); - } - return resultErrors; - } - /** * Unbinds the currently bound bean if any. If there is no bound bean, does * nothing. @@ -725,9 +751,15 @@ public class Binder implements Serializable { /** * Reads the bound property values from the given bean to the corresponding - * fields. The bean is not otherwise associated with this binder; in - * particular its property values are not bound to the field value changes. - * To achieve that, use {@link #bind(BEAN)}. + * fields. + *

+ * The bean is not otherwise associated with this binder; in particular its + * property values are not bound to the field value changes. To achieve + * that, use {@link #bind(BEAN)}. + * + * @see #bind(Object) + * @see #saveIfValid(Object) + * @see #save(Object) * * @param bean * the bean whose property values to read, not null @@ -739,10 +771,15 @@ public class Binder implements Serializable { /** * Saves changes from the bound fields to the given bean if all validators - * pass. + * (binding and bean level) pass. + *

+ * If any field binding validator fails, no values are saved and a + * {@code ValidationException} is thrown. *

- * If any field binding validator fails, no values are saved and an - * exception is thrown. + * If all field level validators pass, the given bean is updated and bean + * level validators are run on the updated item. If any bean level validator + * fails, the bean updates are reverted and a {@code ValidationException} is + * thrown. * * @see #saveIfValid(Object) * @see #load(Object) @@ -762,12 +799,16 @@ public class Binder implements Serializable { /** * Saves changes from the bound fields to the given bean if all validators - * pass. + * (binding and bean level) pass. *

* If any field binding validator fails, no values are saved and * false is returned. + *

+ * If all field level validators pass, the given bean is updated and bean + * level validators are run on the updated item. If any bean level validator + * fails, the bean updates are reverted and false is returned. * - * @see #saveIfValid(Object) + * @see #save(Object) * @see #load(Object) * @see #bind(Object) * @@ -782,21 +823,128 @@ public class Binder implements Serializable { /** * Saves the field values into the given bean if all field level validators - * pass. + * pass. Runs bean level validators on the bean after saving. * * @param bean * the bean to save field values into - * @return a list of field validation errors + * @return a list of field validation errors if such occur, otherwise a list + * of bean validation errors. */ private List> doSaveIfValid(BEAN bean) { Objects.requireNonNull(bean, "bean cannot be null"); // First run fields level validation - List> errors = validate(); + List> errors = validateBindings(); // If no validation errors then update bean - if (errors.isEmpty()) { - bindings.forEach(binding -> binding.storeFieldValue(bean)); + if (!errors.isEmpty()) { + return errors; + } + + // Save old bean values so we can restore them if validators fail + Map, Object> oldValues = new HashMap<>(); + bindings.forEach(binding -> oldValues.put(binding, + binding.convertDataToFieldType(bean))); + + bindings.forEach(binding -> binding.storeFieldValue(bean, false)); + // Now run bean level validation against the updated bean + List> itemValidatorErrors = validateItem(bean); + if (!itemValidatorErrors.isEmpty()) { + // Item validator failed, revert values + bindings.forEach((BindingImpl binding) -> binding.setBeanValue(bean, + oldValues.get(binding))); + } + return itemValidatorErrors; + } + + /** + * Adds an item level validator. + *

+ * Item level validators are applied on the item instance after the item is + * updated. If the validators fail, the item instance is reverted to its + * previous state. + * + * @see #save(Object) + * @see #saveIfValid(Object) + * + * @param validator + * the validator to add, not null + * @return this binder, for chaining + */ + public Binder withValidator(Validator validator) { + Objects.requireNonNull(validator, "validator cannot be null"); + validators.add(validator); + return this; + } + + /** + * Validates the values of all bound fields and returns the result of the + * validation as a list of validation errors. + *

+ * If all field level validators pass, and {@link #bind(Object)} has been + * used to bind to an item, item level validators are run for that bean. + * Item level validators are ignored if there is no bound item or if any + * field level validator fails. + *

+ * Validation is successful if the returned list is empty. + * + * @return a list of validation errors or an empty list if validation + * succeeded + */ + public List> validate() { + List> errors = validateBindings(); + if (!errors.isEmpty()) { + return errors; + } + + if (bean != null) { + return validateItem(bean); + } + + return Collections.emptyList(); + } + + /** + * Validates the bindings and returns the result of the validation as a list + * of validation errors. + *

+ * If all validators pass, the resulting list is empty. + *

+ * Does not run bean validators. + * + * @see #validateItem(Object) + * + * @return a list of validation errors or an empty list if validation + * succeeded + */ + private List> validateBindings() { + List> resultErrors = new ArrayList<>(); + for (BindingImpl binding : bindings) { + binding.validate().ifError(errorMessage -> resultErrors + .add(new ValidationError<>(binding, + binding.getField().getValue(), errorMessage))); } - return errors; + return resultErrors; + } + + /** + * Validates the {@code item} using item validators added using + * {@link #withValidator(Validator)} and returns the result of the + * validation as a list of validation errors. + *

+ * If all validators pass, the resulting list is empty. + * + * @see #withValidator(Validator) + * + * @param bean + * the bean to validate + * @return a list of validation errors or an empty list if validation + * succeeded + */ + private List> validateItem(BEAN bean) { + Objects.requireNonNull(bean, "bean cannot be null"); + return validators.stream().map(validator -> validator.apply(bean)) + .filter(Result::isError).map(res -> new ValidationError<>(this, + bean, res.getMessage().get())) + .collect(Collectors.toList()); } /** diff --git a/server/src/main/java/com/vaadin/data/ValidationError.java b/server/src/main/java/com/vaadin/data/ValidationError.java index 32d9b71c31..1555ecad2c 100644 --- a/server/src/main/java/com/vaadin/data/ValidationError.java +++ b/server/src/main/java/com/vaadin/data/ValidationError.java @@ -17,44 +17,67 @@ package com.vaadin.data; import java.io.Serializable; import java.util.Objects; +import java.util.Optional; + +import com.vaadin.data.Binder.Binding; /** - * Represents a validation error. An error contains a reference to a field whose - * value is invalid and a message describing a validation failure. + * Represents a validation error. + *

+ * A validation error is either connected to a field validator ( + * {@link #getField()} returns a non-empty optional) or to an item level + * validator ({@link #getField()} returns an empty optional). * * @author Vaadin Ltd * @since 8.0 * * @param - * the field value type + * the value type */ public class ValidationError implements Serializable { - private HasValue field; - private String message; + /** + * This is either a {@link Binding} or a {@link Binder}. + */ + private final Object source; + private final String message; + /** + * This is either HasValue value (in case of Binding) or bean (in case of + * Binder). + */ + private final V value; /** - * Creates a new instance of ValidationError with provided validated field - * and error message. + * Creates a new instance of ValidationError using the provided source + * ({@link Binding} or {@link Binder}), value and error message. * - * @param field - * the validated field + * @param source + * the validated binding or the binder + * @param value + * the invalid value * @param message * the validation error message, not {@code null} */ - public ValidationError(HasValue field, String message) { + public ValidationError(Object source, V value, String message) { Objects.requireNonNull(message, "message cannot be null"); - this.field = field; + this.source = source; this.message = message; + this.value = value; } /** - * Returns a reference to the validated field. + * Returns a reference to the validated field or an empty optional if the + * validation was not related to a single field. * - * @return the validated field + * @return the validated field or an empty optional */ - public HasValue getField() { - return field; + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Optional> getField() { + if (source instanceof Binding) { + return Optional.of(((Binding) source).getField()); + } else { + return Optional.empty(); + } } /** @@ -65,4 +88,17 @@ public class ValidationError implements Serializable { public String getMessage() { return message; } + + /** + * Returns the invalid value. + *

+ * This is either the field value (if the validator error comes from a field + * binding) or the bean (for item validators). + * + * @return the source value + */ + public V getValue() { + return value; + } + } diff --git a/server/src/test/java/com/vaadin/data/BeanBinderTest.java b/server/src/test/java/com/vaadin/data/BeanBinderTest.java index 6b535c53a6..1788c3a9e8 100644 --- a/server/src/test/java/com/vaadin/data/BeanBinderTest.java +++ b/server/src/test/java/com/vaadin/data/BeanBinderTest.java @@ -13,12 +13,12 @@ import com.vaadin.ui.TextField; public class BeanBinderTest { - BeanBinder binder; + private BeanBinder binder; - TextField nameField; - TextField ageField; + private TextField nameField; + private TextField ageField; - BeanToValidate p = new BeanToValidate(); + private BeanToValidate p = new BeanToValidate(); @Before public void setUp() { @@ -165,7 +165,7 @@ public class BeanBinderTest { private void assertInvalid(HasValue field, String message) { List> errors = binder.validate(); assertEquals(1, errors.size()); - assertSame(field, errors.get(0).getField()); + assertSame(field, errors.get(0).getField().get()); assertEquals(message, errors.get(0).getMessage()); } } diff --git a/server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java b/server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java index a50e06d10f..6ed2170fa7 100644 --- a/server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java +++ b/server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java @@ -28,10 +28,11 @@ import com.vaadin.data.Binder.Binding; import com.vaadin.data.util.converter.StringToIntegerConverter; import com.vaadin.data.validator.EmailValidator; import com.vaadin.server.AbstractErrorMessage; -import com.vaadin.ui.AbstractField; +import com.vaadin.ui.Button; import com.vaadin.ui.Label; import com.vaadin.ui.PopupDateField; import com.vaadin.ui.Slider; +import com.vaadin.ui.TextField; /** * Book of Vaadin tests. @@ -41,25 +42,23 @@ import com.vaadin.ui.Slider; */ public class BinderBookOfVaadinTest { - static class TextField extends AbstractField { - - String value = ""; + private static class BookPerson { + private String lastName; + private String email, phone, title; + private int yearOfBirth, salaryLevel; - @Override - public String getValue() { - return value; + public BookPerson(int yearOfBirth, int salaryLevel) { + this.yearOfBirth = yearOfBirth; + this.salaryLevel = salaryLevel; } - @Override - protected void doSetValue(String value) { - this.value = value; + public BookPerson(BookPerson origin) { + this(origin.yearOfBirth, origin.salaryLevel); + lastName = origin.lastName; + email = origin.email; + phone = origin.phone; + title = origin.title; } - } - - private static class BookPerson { - private String lastName; - private String email; - private int yearOfBirth, salaryLevel; public String getLastName() { return lastName; @@ -69,11 +68,6 @@ public class BinderBookOfVaadinTest { this.lastName = lastName; } - public BookPerson(int yearOfBirth, int salaryLevel) { - this.yearOfBirth = yearOfBirth; - this.salaryLevel = salaryLevel; - } - public int getYearOfBirth() { return yearOfBirth; } @@ -98,6 +92,22 @@ public class BinderBookOfVaadinTest { this.email = email; } + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + } public static class Trip { @@ -115,11 +125,15 @@ public class BinderBookOfVaadinTest { private Binder binder; private TextField field; + private TextField phoneField; + private TextField emailField; @Before public void setUp() { binder = new Binder<>(); field = new TextField(); + phoneField = new TextField(); + emailField = new TextField(); } @Test @@ -421,4 +435,45 @@ public class BinderBookOfVaadinTest { Assert.assertEquals(field, evt.getSource()); } + @Test + public void binder_saveIfValid() { + BeanBinder binder = new BeanBinder( + BookPerson.class); + + // Phone or email has to be specified for the bean + Validator phoneOrEmail = Validator.from( + personBean -> !"".equals(personBean.getPhone()) + || !"".equals(personBean.getEmail()), + "A person must have either a phone number or an email address"); + binder.withValidator(phoneOrEmail); + + binder.forField(emailField).bind("email"); + binder.forField(phoneField).bind("phone"); + + // Person person = // e.g. JPA entity or bean from Grid + BookPerson person = new BookPerson(1900, 5); + person.setEmail("Old Email"); + // Load person data to a form + binder.load(person); + + Button saveButton = new Button("Save", event -> { + // Using saveIfValid to avoid the try-catch block that is + // needed if using the regular save method + if (binder.saveIfValid(person)) { + // Person is valid and updated + // TODO Store in the database + } + }); + + emailField.setValue("foo@bar.com"); + Assert.assertTrue(binder.saveIfValid(person)); + // Person updated + Assert.assertEquals("foo@bar.com", person.getEmail()); + + emailField.setValue(""); + Assert.assertFalse(binder.saveIfValid(person)); + // Person updated because phone and email are both empty + Assert.assertEquals("foo@bar.com", person.getEmail()); + } + } diff --git a/server/src/test/java/com/vaadin/data/BinderTest.java b/server/src/test/java/com/vaadin/data/BinderTest.java index be408d6142..4c59773b2f 100644 --- a/server/src/test/java/com/vaadin/data/BinderTest.java +++ b/server/src/test/java/com/vaadin/data/BinderTest.java @@ -5,7 +5,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -217,10 +219,46 @@ public class BinderTest { try { binder.save(person); } finally { + // Bean should not have been updated Assert.assertEquals(firstName, person.getFirstName()); } } + @Test(expected = ValidationException.class) + public void save_beanValidationErrors() throws ValidationException { + Binder binder = new Binder<>(); + binder.forField(nameField).withValidator(new NotEmptyValidator<>("a")) + .bind(Person::getFirstName, Person::setFirstName); + + binder.withValidator(Validator.from(person -> false, "b")); + + Person person = new Person(); + nameField.setValue("foo"); + try { + binder.save(person); + } finally { + // Bean should have been updated for item validation but reverted + Assert.assertNull(person.getFirstName()); + } + } + + @Test + public void save_fieldsAndBeanLevelValidation() throws ValidationException { + Binder binder = new Binder<>(); + binder.forField(nameField).withValidator(new NotEmptyValidator<>("a")) + .bind(Person::getFirstName, Person::setFirstName); + + binder.withValidator( + Validator.from(person -> person.getLastName() != null, "b")); + + Person person = new Person(); + person.setLastName("bar"); + nameField.setValue("foo"); + binder.save(person); + Assert.assertEquals(nameField.getValue(), person.getFirstName()); + Assert.assertEquals("bar", person.getLastName()); + } + @Test public void saveIfValid_fieldValidationErrors() { Binder binder = new Binder<>(); @@ -250,6 +288,60 @@ public class BinderTest { Assert.assertEquals("bar", person.getFirstName()); } + @Test + public void saveIfValid_beanValidationErrors() { + Binder binder = new Binder<>(); + binder.forField(nameField).bind(Person::getFirstName, + Person::setFirstName); + + String msg = "foo"; + binder.withValidator(Validator. from( + prsn -> prsn.getAddress() != null || prsn.getEmail() != null, + msg)); + + Person person = new Person(); + person.setFirstName("foo"); + nameField.setValue(""); + Assert.assertFalse(binder.saveIfValid(person)); + + Assert.assertEquals("foo", person.getFirstName()); + } + + @Test + public void save_validationErrors_exceptionContainsErrors() + throws ValidationException { + Binder binder = new Binder<>(); + String msg = "foo"; + Binding nameBinding = binder.forField(nameField) + .withValidator(new NotEmptyValidator<>(msg)); + nameBinding.bind(Person::getFirstName, Person::setFirstName); + + Binding ageBinding = binder.forField(ageField) + .withConverter(stringToInteger).withValidator(notNegative); + ageBinding.bind(Person::getAge, Person::setAge); + + Person person = new Person(); + nameField.setValue(""); + ageField.setValue("-1"); + try { + binder.save(person); + Assert.fail(); + } catch (ValidationException exception) { + List> validationErrors = exception + .getValidationError(); + Assert.assertEquals(2, validationErrors.size()); + ValidationError error = validationErrors.get(0); + Assert.assertEquals(nameField, error.getField().get()); + Assert.assertEquals(msg, error.getMessage()); + Assert.assertEquals("", error.getValue()); + + error = validationErrors.get(1); + Assert.assertEquals(ageField, error.getField().get()); + Assert.assertEquals("Value must be positive", error.getMessage()); + Assert.assertEquals(ageField.getValue(), error.getValue()); + } + } + @Test public void load_bound_fieldValueIsUpdated() { Binder binder = new Binder<>(); @@ -328,6 +420,7 @@ public class BinderTest { Assert.assertTrue(errorMessages.contains(msg1)); Set fields = errors.stream().map(ValidationError::getField) + .filter(Optional::isPresent).map(Optional::get) .collect(Collectors.toSet()); Assert.assertEquals(1, fields.size()); Assert.assertTrue(fields.contains(nameField)); @@ -637,4 +730,139 @@ public class BinderTest { }); } + @Test + public void validate_failedBeanValidatorWithoutFieldValidators() { + Binder binder = new Binder<>(); + binder.forField(nameField).bind(Person::getFirstName, + Person::setFirstName); + + String msg = "foo"; + binder.withValidator(Validator.from(bean -> false, msg)); + Person person = new Person(); + binder.bind(person); + + List> errors = binder.validate(); + Assert.assertEquals(1, errors.size()); + Assert.assertFalse(errors.get(0).getField().isPresent()); + } + + @Test + public void validate_failedBeanValidatorWithFieldValidator() { + String msg = "foo"; + + Binder binder = new Binder<>(); + Binding binding = binder.forField(nameField) + .withValidator(new NotEmptyValidator<>(msg)); + binding.bind(Person::getFirstName, Person::setFirstName); + + binder.withValidator(Validator.from(bean -> false, msg)); + Person person = new Person(); + binder.bind(person); + + List> errors = binder.validate(); + Assert.assertEquals(1, errors.size()); + ValidationError error = errors.get(0); + Assert.assertEquals(msg, error.getMessage()); + Assert.assertTrue(error.getField().isPresent()); + Assert.assertEquals(nameField.getValue(), error.getValue()); + } + + @Test + public void validate_failedBothBeanValidatorAndFieldValidator() { + String msg1 = "foo"; + + Binder binder = new Binder<>(); + Binding binding = binder.forField(nameField) + .withValidator(new NotEmptyValidator<>(msg1)); + binding.bind(Person::getFirstName, Person::setFirstName); + + String msg2 = "bar"; + binder.withValidator(Validator.from(bean -> false, msg2)); + Person person = new Person(); + binder.bind(person); + + List> errors = binder.validate(); + Assert.assertEquals(1, errors.size()); + + ValidationError error = errors.get(0); + + Assert.assertEquals(msg1, error.getMessage()); + Assert.assertEquals(nameField, error.getField().get()); + Assert.assertEquals(nameField.getValue(), error.getValue()); + } + + @Test + public void validate_okBeanValidatorWithoutFieldValidators() { + Binder binder = new Binder<>(); + binder.forField(nameField).bind(Person::getFirstName, + Person::setFirstName); + + String msg = "foo"; + binder.withValidator(Validator.from(bean -> true, msg)); + Person person = new Person(); + binder.bind(person); + + List> errors = binder.validate(); + Assert.assertEquals(0, errors.size()); + } + + @Test + public void binder_saveIfValid() { + String msg1 = "foo"; + Binder binder = new Binder<>(); + Binding binding = binder.forField(nameField) + .withValidator(new NotEmptyValidator<>(msg1)); + binding.bind(Person::getFirstName, Person::setFirstName); + + String beanValidatorErrorMessage = "bar"; + binder.withValidator( + Validator.from(bean -> false, beanValidatorErrorMessage)); + Person person = new Person(); + String firstName = "first name"; + person.setFirstName(firstName); + binder.load(person); + + nameField.setValue(""); + Assert.assertFalse(binder.saveIfValid(person)); + // check that field level-validation failed and bean is not updated + Assert.assertEquals(firstName, person.getFirstName()); + + nameField.setValue("new name"); + + Assert.assertFalse(binder.saveIfValid(person)); + // Bean is updated but reverted + Assert.assertEquals(firstName, person.getFirstName()); + } + + @Test + public void updateBoundField_bindingValdationFails_beanLevelValidationIsNotRun() { + bindAgeWithValidatorConverterValidator(); + bindName(); + + AtomicBoolean beanLevelValidationRun = new AtomicBoolean(); + binder.withValidator(Validator. from( + bean -> beanLevelValidationRun.getAndSet(true), "")); + + ageField.setValue("not a number"); + + Assert.assertFalse(beanLevelValidationRun.get()); + + nameField.setValue("foo"); + Assert.assertFalse(beanLevelValidationRun.get()); + } + + @Test + public void updateBoundField_bindingValdationSuccess_beanLevelValidationIsRun() { + bindAgeWithValidatorConverterValidator(); + bindName(); + + AtomicBoolean beanLevelValidationRun = new AtomicBoolean(); + binder.withValidator(Validator. from( + bean -> beanLevelValidationRun.getAndSet(true), "")); + + ageField.setValue(String.valueOf(12)); + + Assert.assertTrue(beanLevelValidationRun.get()); + } + } -- 2.39.5