Skip to content

Commit

Permalink
feat: Binder record support (#19806)
Browse files Browse the repository at this point in the history
* feat: Binder record support

* extract ReflectTools exception handling

* ensure record param order; fix exception handling

* fix javadoc

* Check for and throw if binding is missing

* Improve javadocs and exceptions
  • Loading branch information
tepi authored Aug 23, 2024
1 parent e0e2195 commit 9bc30b5
Show file tree
Hide file tree
Showing 3 changed files with 222 additions and 32 deletions.
154 changes: 141 additions & 13 deletions flow-data/src/main/java/com/vaadin/flow/data/binder/Binder.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.io.Serializable;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
Expand Down Expand Up @@ -1053,7 +1054,7 @@ public Binding<BEAN, TARGET> bind(ValueProvider<BEAN, TARGET> getter,
if (getBinder().getBean() != null) {
binding.initFieldValue(getBinder().getBean(), true);
}
if (setter == null) {
if (setter == null && !binder.isRecord) {
binding.getField().setReadOnly(true);
}
getBinder().fireStatusChangeEvent(false);
Expand Down Expand Up @@ -1792,6 +1793,10 @@ void setIdentity() {

private BEAN bean;

private boolean isRecord;

private Class<BEAN> beanType;

private final Collection<Binding<BEAN, ?>> bindings = new ArrayList<>();

private Map<HasValue<?, ?>, BindingBuilder<BEAN, ?>> incompleteBindings;
Expand Down Expand Up @@ -1834,8 +1839,8 @@ protected Binder(PropertySet<BEAN> propertySet) {
}

/**
* Creates a new binder that uses reflection based on the provided bean type
* to resolve bean properties.
* Creates a new binder that uses reflection based on the provided bean or
* record type to resolve its properties.
*
* Nested properties are resolved lazily, when bound to a field.
*
Expand All @@ -1844,6 +1849,10 @@ protected Binder(PropertySet<BEAN> propertySet) {
*/
public Binder(Class<BEAN> beanType) {
this(BeanPropertySet.get(beanType));
isRecord = beanType.isRecord();
if (isRecord) {
this.beanType = beanType;
}
}

/**
Expand Down Expand Up @@ -1874,8 +1883,8 @@ public Binder() {
}

/**
* Creates a new binder that uses reflection based on the provided bean type
* to resolve bean properties.
* Creates a new binder that uses reflection based on the provided bean or
* record type to resolve its properties.
*
* If {@code scanNestedDefinitions} is true, nested properties are detected
* eagerly. Otherwise, they will be discovered lazily when the property is
Expand All @@ -1889,6 +1898,10 @@ public Binder() {
public Binder(Class<BEAN> beanType, boolean scanNestedDefinitions) {
this(BeanPropertySet.get(beanType, scanNestedDefinitions,
PropertyFilterDefinition.getDefaultFilter()));
isRecord = beanType.isRecord();
if (isRecord) {
this.beanType = beanType;
}
}

/**
Expand Down Expand Up @@ -2247,8 +2260,14 @@ public <FIELDVALUE> Binding<BEAN, FIELDVALUE> bindReadOnly(
* @param bean
* the bean to edit, or {@code null} to remove a currently bound
* bean and clear bound fields
* @throws IllegalStateException
* if the Binder's model type is record
*/
public void setBean(BEAN bean) {
if (isRecord) {
throw new IllegalStateException(
"setBean can't be used with records, call readBean instead");
}
checkBindingsCompleted("setBean");
if (bean == null) {
if (this.bean != null) {
Expand Down Expand Up @@ -2278,20 +2297,21 @@ public void removeBean() {
}

/**
* Reads the bound property values from the given bean to the corresponding
* fields.
* Reads the bound property values from the given bean or record to the
* corresponding fields.
* <p>
* 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 #setBean(Object)}.
* The bean or record 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 #setBean(Object)}.
*
* @see #setBean(Object)
* @see #writeBeanIfValid(Object)
* @see #writeBean(Object)
* @see #writeRecord()
*
* @param bean
* the bean whose property values to read or {@code null} to
* clear bound fields
* the bean or record whose property values to read or
* {@code null} to clear bound fields
*/
public void readBean(BEAN bean) {
checkBindingsCompleted("readBean");
Expand Down Expand Up @@ -2508,6 +2528,103 @@ public boolean writeBeanIfValid(BEAN bean) {
return doWriteIfValid(bean, bindings).isOk();
}

/**
* Writes values from the bound fields to a new record instance if all
* validators (binding and bean level) pass. This method can only be used if
* Binder was originally configured to use a record type.
* <p>
* If any field binding validator fails, no values are written and a
* {@code ValidationException} is thrown.
* <p>
* If all field level validators pass, a record is intanciated and bean
* level validators are run on the new record. If any bean level validator
* fails a {@code ValidationException} is thrown.
*
* @see #readBean(Object)
*
* @return a record instance with current values
* @throws ValidationException
* if some of the bound field values fail to validate
* @throws IllegalStateException
* if a record component does not have a binding, or if the
* Binder's model type is bean
* @throws IllegalArgumentException
* if record instantiation fails for any reason
*/
public BEAN writeRecord() throws ValidationException {
if (!isRecord) {
throw new IllegalStateException(
"writeRecord methods can't be used with beans, call writeBean instead");
}
BEAN record = null;
List<ValidationResult> binderResults = Collections.emptyList();

// make a copy of the incoming bindings to avoid their modifications
// during validation
Collection<Binding<BEAN, ?>> currentBindings = new ArrayList<>(
bindings);

// First run fields level validation, if no validation errors then
// create a record.
List<BindingValidationStatus<?>> bindingResults = currentBindings
.stream().map(b -> b.validate(false))
.collect(Collectors.toList());

if (bindingResults.stream()
.noneMatch(BindingValidationStatus::isError)) {
// Field level validation can be skipped as it was done already
boolean validatorsDisabledStatus = isValidatorsDisabled();
setValidatorsDisabled(true);
// Fetch all conversion results
List<Result<?>> values = new ArrayList<>();
for (RecordComponent rc : beanType.getRecordComponents()) {
String name = rc.getName();
if (boundProperties.containsKey(name)) {
Result<?> value = ((BindingImpl<BEAN, ?, ?>) boundProperties
.get(name)).doConversion();
values.add(value);
} else {
throw new IllegalStateException(
"Unable to create record since no "
+ "binding was found for record component '"
+ name
+ "'. Please create bindings for all record components "
+ "using their names as the propertyName.");
}
}
setValidatorsDisabled(validatorsDisabledStatus);

// Gather successfully converted values
final List<Object> convertedValues = new ArrayList<>();
values.forEach(value -> value.ifOk(convertedValues::add));

try {
record = beanType.cast(beanType.getDeclaredConstructors()[0]
.newInstance(convertedValues.toArray()));
} catch (Exception e) {
throw ReflectTools.convertInstantiationException(e, beanType);
}

// Now run bean level validation against the created record
bean = record;
binderResults = validateBean(bean);
bean = null;
if (binderResults.stream().noneMatch(ValidationResult::isError)) {
changedBindings.clear();
}
}

// Generate status object and fire events.
BinderValidationStatus<BEAN> status = new BinderValidationStatus<>(this,
bindingResults, binderResults);
getValidationStatusHandler().statusChange(status);
fireStatusChangeEvent(!status.isOk());
if (!status.isOk()) {
throw new ValidationException(bindingResults, binderResults);
}
return record;
}

/**
* Writes the field values into the given bean if all field level validators
* pass. Runs bean level validators on the bean after writing.
Expand All @@ -2521,10 +2638,16 @@ public boolean writeBeanIfValid(BEAN bean) {
* the set of bindings to write to the bean
* @return a list of field validation errors if such occur, otherwise a list
* of bean validation errors.
* @throws IllegalStateException
* if the Binder's model type is record
*/
@SuppressWarnings("unchecked")
private BinderValidationStatus<BEAN> doWriteIfValid(BEAN bean,
Collection<Binding<BEAN, ?>> bindings) {
if (isRecord) {
throw new IllegalStateException(
"writeBean methods can't be used with records, call writeRecord instead");
}
Objects.requireNonNull(bean, "bean cannot be null");
List<ValidationResult> binderResults = Collections.emptyList();

Expand Down Expand Up @@ -2592,12 +2715,17 @@ private BinderValidationStatus<BEAN> doWriteIfValid(BEAN bean,
* the set of bindings to write to the bean
* @param forced
* disable validators during write if true
* @throws IllegalStateException
* if the Binder's model type is record
*/
@SuppressWarnings({ "unchecked" })
private void doWriteDraft(BEAN bean, Collection<Binding<BEAN, ?>> bindings,
boolean forced) {
Objects.requireNonNull(bean, "bean cannot be null");

if (isRecord) {
throw new IllegalStateException(
"writeBean methods can't be used with records, call writeRecord instead");
}
if (!forced) {
bindings.forEach(binding -> ((BindingImpl<BEAN, ?, ?>) binding)
.writeFieldValue(bean));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2678,6 +2678,43 @@ public void withConverter_hasChangesFalse() {
assertEquals("Name", nameField.getValue());
}

public record TestRecord(String name, int age) {
}

@Test
public void readRecord_writeRecord() throws ValidationException {
Binder<TestRecord> binder = new Binder<>(TestRecord.class);

TestTextField nameField = new TestTextField();
nameField.setValue("");
TestTextField ageField = new TestTextField();
ageField.setValue("");

binder.forField(ageField)
.withConverter(
new StringToIntegerConverter(0, "Failed to convert"))
.bind("age");
binder.forField(nameField).bind("name");
binder.readBean(new TestRecord("test", 42));

// Check that fields are enabled for records
Assert.assertFalse(nameField.isReadOnly());
Assert.assertFalse(ageField.isReadOnly());

// Check valid record writing
nameField.setValue("foo");
ageField.setValue("50");
TestRecord testRecord = binder.writeRecord();
Assert.assertEquals("foo", testRecord.name);
Assert.assertEquals(50, testRecord.age);

// Check that invalid record writing fails
ageField.setValue("invalid value");
assertThrows(ValidationException.class, () -> {
TestRecord failedRecord = binder.writeRecord();
});
}

private TestTextField createNullRejectingFieldWithEmptyValue(
String emptyValue) {
return new TestTextField() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,33 +493,58 @@ public static <T> T createProxyInstance(Class<T> proxyClass,
return proxyClass.cast(constructor.get().newInstance(
Array.newInstance(paramType.getComponentType(), 0)));
}
} catch (InstantiationException e) {
if (originalClass.isMemberClass()
&& !Modifier.isStatic(originalClass.getModifiers())) {
throw new IllegalArgumentException(String.format(
} catch (Exception e) {
throw convertInstantiationException(e, originalClass);
}
throw new IllegalArgumentException(String.format(
CREATE_INSTANCE_FAILED_NO_PUBLIC_NOARG_CONSTRUCTOR,
originalClass.getName()));
}

/**
* Helper to handle all exceptions which might occur during class
* instantiation and returns an {@link IllegalArgumentException} with a
* descriptive error message hinting of what might be wrong with the class
* that could not be instantiated. Descriptive message is derived based on
* the information about the {@code clazz}.
*
* @param exception
* original exception
* @param clazz
* instantiation target class
* @return an IllegalArgumentException with descriptive message
*/
public static IllegalArgumentException convertInstantiationException(
Exception exception, Class<?> clazz) {
if (exception instanceof InstantiationException) {
if (clazz.isMemberClass()
&& !Modifier.isStatic(clazz.getModifiers())) {
return new IllegalArgumentException(String.format(
CREATE_INSTANCE_FAILED_FOR_NON_STATIC_MEMBER_CLASS,
originalClass.getName()), e);
clazz.getName()), exception);
} else {
throw new IllegalArgumentException(String.format(
CREATE_INSTANCE_FAILED, originalClass.getName()), e);
return new IllegalArgumentException(
String.format(CREATE_INSTANCE_FAILED, clazz.getName()),
exception);
}
} catch (IllegalAccessException e) {
throw new IllegalArgumentException(
} else if (exception instanceof IllegalAccessException) {
return new IllegalArgumentException(
String.format(CREATE_INSTANCE_FAILED_ACCESS_EXCEPTION,
originalClass.getName()),
e);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(String.format(
CREATE_INSTANCE_FAILED, originalClass.getName()), e);
} catch (InvocationTargetException e) {
throw new IllegalArgumentException(String.format(
clazz.getName()),
exception);
} else if (exception instanceof IllegalArgumentException) {
return new IllegalArgumentException(
String.format(CREATE_INSTANCE_FAILED, clazz.getName()),
exception);
} else if (exception instanceof InvocationTargetException) {
return new IllegalArgumentException(String.format(
CREATE_INSTANCE_FAILED_CONSTRUCTOR_THREW_EXCEPTION,
originalClass.getName()), e);
clazz.getName()), exception);
}

throw new IllegalArgumentException(String.format(
return new IllegalArgumentException(String.format(
CREATE_INSTANCE_FAILED_NO_PUBLIC_NOARG_CONSTRUCTOR,
originalClass.getName()));
clazz.getName()));
}

/**
Expand Down

0 comments on commit 9bc30b5

Please sign in to comment.