Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Binder record support #19806

Merged
merged 8 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
mshabarov marked this conversation as resolved.
Show resolved Hide resolved
*/
public BEAN writeRecord() throws ValidationException {
mshabarov marked this conversation as resolved.
Show resolved Hide resolved
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()));
tepi marked this conversation as resolved.
Show resolved Hide resolved
} 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
Loading