Skip to content

Port BeanPropertyRowMapper and DataClassRowMapper for r2dbc #30530

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

Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,463 @@
/*
* Copyright 2002-2023 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
*
* https://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.r2dbc.core;

import java.beans.PropertyDescriptor;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

import io.r2dbc.spi.OutParameters;
import io.r2dbc.spi.OutParametersMetadata;
import io.r2dbc.spi.Readable;
import io.r2dbc.spi.ReadableMetadata;
import io.r2dbc.spi.Row;
import io.r2dbc.spi.RowMetadata;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.beans.BeanUtils;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.TypeConverter;
import org.springframework.beans.TypeMismatchException;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;

/**
* Mapping {@code Function} implementation that converts an R2DBC {@code Readable}
* (a {@code Row} or {@code OutParameters}) into a new instance of the specified mapped
* target class. The mapped target class must be a top-level class or {@code static}
* nested class, and it must have a default or no-arg constructor.
*
* <p>
* Readable component values are mapped based on matching the name (as obtained from R2DBC
* meta-data) to public setters in the target class for the corresponding properties. The
* names are matched either directly or by transforming a name separating the parts with
* underscores to the same name using "camel" case.
*
* <p>
* Mapping is provided for properties in the target class for many common types &mdash;
* for example: String, boolean, Boolean, byte, Byte, short, Short, int, Integer, long,
* Long, float, Float, double, Double, BigDecimal, {@code java.util.Date}, etc.
*
* <p>
* To facilitate mapping between columns and properties that don't have matching names,
* try using column aliases in the SQL statement like
* {@code "select fname as first_name from customer"}, where {@code first_name} can be
* mapped to a {@code setFirstName(String)} method in the target class.
*
* <p>
* For a {@code NULL} value read from the database, an attempt will be made to call the
* corresponding setter method with {@code null}, but in the case of Java primitives this
* will result in a {@link TypeMismatchException} by default. To ignore {@code NULL}
* database values for all primitive properties in the target class, set the
* {@code primitivesDefaultedForNullValue} flag to {@code true}. See
* {@link #setPrimitivesDefaultedForNullValue(boolean)} for details.
*
* <p>
* If you need to map to a target class which has a <em>data class</em> constructor
* &mdash; for example, a Java {@code record} or a Kotlin {@code data} class &mdash; use
* {@link DataClassRowMapper} instead.
*
* <p>
* Please note that this class is designed to provide convenience rather than high
* performance. For best performance, consider using a custom mapping function
* implementation.
*
* @author Simon Baslé
* @since 6.1
* @param <T> the result type
* @see DataClassRowMapper
*/
// Note: this class is adapted from the BeanPropertyRowMapper in spring-jdbc
public class BeanPropertyRowMapper<T> implements Function<Readable, T> {

/** Logger available to subclasses. */
protected final Log logger = LogFactory.getLog(getClass());

/** The class we are mapping to. */
@Nullable
private Class<T> mappedClass;

/** Whether we're strictly validating. */
private boolean checkFullyPopulated = false;

/**
* Whether {@code NULL} database values should be ignored for primitive
* properties in the target class.
* @see #setPrimitivesDefaultedForNullValue(boolean)
*/
private boolean primitivesDefaultedForNullValue = false;

/** ConversionService for binding R2DBC values to bean properties. */
@Nullable
private ConversionService conversionService = DefaultConversionService.getSharedInstance();

/** Map of the properties we provide mapping for. */
@Nullable
private Map<String, PropertyDescriptor> mappedProperties;

/** Set of bean property names we provide mapping for. */
@Nullable
private Set<String> mappedPropertyNames;

/**
* Create a new {@code BeanPropertyRowMapper}, accepting unpopulated
* properties in the target bean.
* @param mappedClass the class that each row/outParameters should be mapped to
*/
public BeanPropertyRowMapper(Class<T> mappedClass) {
initialize(mappedClass);
}

/**
* Create a new {@code BeanPropertyRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @param checkFullyPopulated whether we're strictly validating that
* all bean properties have been mapped from corresponding database columns or
* out-parameters
*/
public BeanPropertyRowMapper(Class<T> mappedClass, boolean checkFullyPopulated) {
initialize(mappedClass);
this.checkFullyPopulated = checkFullyPopulated;
}


/**
* Get the class that we are mapping to.
*/
@Nullable
public final Class<T> getMappedClass() {
return this.mappedClass;
}

/**
* Set whether we're strictly validating that all bean properties have been mapped
* from corresponding database columns or out-parameters.
* <p>Default is {@code false}, accepting unpopulated properties in the target bean.
*/
public void setCheckFullyPopulated(boolean checkFullyPopulated) {
this.checkFullyPopulated = checkFullyPopulated;
}

/**
* Return whether we're strictly validating that all bean properties have been
* mapped from corresponding database columns or out-parameters.
*/
public boolean isCheckFullyPopulated() {
return this.checkFullyPopulated;
}

/**
* Set whether a {@code NULL} database column or out-parameter value should
* be ignored when mapping to a corresponding primitive property in the target class.
* <p>Default is {@code false}, throwing an exception when nulls are mapped
* to Java primitives.
* <p>If this flag is set to {@code true} and you use an <em>ignored</em>
* primitive property value from the mapped bean to update the database, the
* value in the database will be changed from {@code NULL} to the current value
* of that primitive property. That value may be the property's initial value
* (potentially Java's default value for the respective primitive type), or
* it may be some other value set for the property in the default constructor
* (or initialization block) or as a side effect of setting some other property
* in the mapped bean.
*/
public void setPrimitivesDefaultedForNullValue(boolean primitivesDefaultedForNullValue) {
this.primitivesDefaultedForNullValue = primitivesDefaultedForNullValue;
}

/**
* Get the value of the {@code primitivesDefaultedForNullValue} flag.
* @see #setPrimitivesDefaultedForNullValue(boolean)
*/
public boolean isPrimitivesDefaultedForNullValue() {
return this.primitivesDefaultedForNullValue;
}

/**
* Set a {@link ConversionService} for binding R2DBC values to bean properties,
* or {@code null} for none.
* <p>Default is a {@link DefaultConversionService}. This provides support for
* {@code java.time} conversion and other special types.
* @see #initBeanWrapper(BeanWrapper)
*/
public void setConversionService(@Nullable ConversionService conversionService) {
this.conversionService = conversionService;
}

/**
* Return a {@link ConversionService} for binding R2DBC values to bean properties,
* or {@code null} if none.
*/
@Nullable
public ConversionService getConversionService() {
return this.conversionService;
}


/**
* Initialize the mapping meta-data for the given class.
* @param mappedClass the mapped class
*/
protected void initialize(Class<T> mappedClass) {
this.mappedClass = mappedClass;
this.mappedProperties = new HashMap<>();
this.mappedPropertyNames = new HashSet<>();

for (PropertyDescriptor pd : BeanUtils.getPropertyDescriptors(mappedClass)) {
if (pd.getWriteMethod() != null) {
String lowerCaseName = lowerCaseName(pd.getName());
this.mappedProperties.put(lowerCaseName, pd);
String underscoreName = underscoreName(pd.getName());
if (!lowerCaseName.equals(underscoreName)) {
this.mappedProperties.put(underscoreName, pd);
}
this.mappedPropertyNames.add(pd.getName());
}
}
}

/**
* Remove the specified property from the mapped properties.
* @param propertyName the property name (as used by property descriptors)
*/
protected void suppressProperty(String propertyName) {
if (this.mappedProperties != null) {
this.mappedProperties.remove(lowerCaseName(propertyName));
this.mappedProperties.remove(underscoreName(propertyName));
}
}

/**
* Convert the given name to lower case.
* By default, conversions will happen within the US locale.
* @param name the original name
* @return the converted name
*/
protected String lowerCaseName(String name) {
return name.toLowerCase(Locale.US);
}

/**
* Convert a name in camelCase to an underscored name in lower case.
* Any upper case letters are converted to lower case with a preceding underscore.
* @param name the original name
* @return the converted name
* @see #lowerCaseName
*/
protected String underscoreName(String name) {
if (!StringUtils.hasLength(name)) {
return "";
}

StringBuilder result = new StringBuilder();
result.append(Character.toLowerCase(name.charAt(0)));
for (int i = 1; i < name.length(); i++) {
char c = name.charAt(i);
if (Character.isUpperCase(c)) {
result.append('_').append(Character.toLowerCase(c));
}
else {
result.append(c);
}
}
return result.toString();
}

/**
* Extract the values for the current {@code Readable} :
* all columns in case of a {@code Row} or all parameters in
* case of an {@code OutParameters}.
* <p>Utilizes public setters and derives meta-data from the
* concrete type.
* @throws UnsupportedOperationException in case the concrete type
* is neither {@code Row} nor {@code OutParameters}
* @see RowMetadata
* @see OutParametersMetadata
*/
@Override
public T apply(Readable readable) {
if (readable instanceof Row row) {
return mapForReadable(row, row.getMetadata().getColumnMetadatas());
}
if (readable instanceof OutParameters out) {
return mapForReadable(out, out.getMetadata().getParameterMetadatas());
}
throw new IllegalArgumentException("Can only map Readable Row or OutParameters, got " + readable.getClass().getName());
}

private <R extends Readable> T mapForReadable(R readable, List<? extends ReadableMetadata> readableMetadatas) {
BeanWrapperImpl bw = new BeanWrapperImpl();
initBeanWrapper(bw);

T mappedObject = constructMappedInstance(readable, readableMetadatas, bw);
bw.setBeanInstance(mappedObject);

Set<String> populatedProperties = (isCheckFullyPopulated() ? new HashSet<>() : null);
int readableItemCount = readableMetadatas.size();
for(int itemIndex = 0; itemIndex < readableItemCount; itemIndex++) {
ReadableMetadata itemMetadata = readableMetadatas.get(itemIndex);
String itemName = itemMetadata.getName();
String property = lowerCaseName(StringUtils.delete(itemName, " "));
PropertyDescriptor pd = (this.mappedProperties != null ? this.mappedProperties.get(property) : null);
if (pd != null) {
Object value = getItemValue(readable, itemIndex, pd);
//Implementation note: the JDBC mapper can log the column mapping details each time row 0 is encountered
// but unfortunately this is not possible in R2DBC as row number is not provided. The BiFunction#apply
// cannot be stateful as it could be applied to a different row set, e.g. when resubscribing.
try {
bw.setPropertyValue(pd.getName(), value);
}
catch (TypeMismatchException ex) {
if (value == null && this.primitivesDefaultedForNullValue) {
if (logger.isDebugEnabled()) {
String propertyType = ClassUtils.getQualifiedName(pd.getPropertyType());
//here too, we miss the rowNumber information
logger.debug("""
Ignoring intercepted TypeMismatchException for item '%s' \
with null value when setting property '%s' of type '%s' on object: %s"
""".formatted(itemName, pd.getName(), propertyType, mappedObject), ex);
}
}
else {
throw ex;
}
}
if (populatedProperties != null) {
populatedProperties.add(pd.getName());
}
}
}

if (populatedProperties != null && !populatedProperties.equals(this.mappedPropertyNames)) {
throw new InvalidDataAccessApiUsageException("Given readable does not contain all items " +
"necessary to populate object of " + this.mappedClass + ": " + this.mappedPropertyNames);
}

return mappedObject;
}

/**
* Construct an instance of the mapped class for the current {@code Readable}.
* <p>
* The default implementation simply instantiates the mapped class. Can be overridden
* in subclasses.
* @param readable the {@code Readable} being mapped (a {@code Row} or {@code OutParameters})
* @param itemMetadatas the list of item {@code ReadableMetadata} (either
* {@code ColumnMetadata} or {@code OutParameterMetadata})
* @param tc a TypeConverter with this row mapper's conversion service
* @return a corresponding instance of the mapped class
*/
protected T constructMappedInstance(Readable readable, List<? extends ReadableMetadata> itemMetadatas, TypeConverter tc) {
Assert.state(this.mappedClass != null, "Mapped class was not specified");
return BeanUtils.instantiateClass(this.mappedClass);
}

/**
* Initialize the given BeanWrapper to be used for row mapping or outParameters
* mapping.
* To be called for each Readable.
* <p>The default implementation applies the configured {@link ConversionService},
* if any. Can be overridden in subclasses.
* @param bw the BeanWrapper to initialize
* @see #getConversionService()
* @see BeanWrapper#setConversionService
*/
protected void initBeanWrapper(BeanWrapper bw) {
ConversionService cs = getConversionService();
if (cs != null) {
bw.setConversionService(cs);
}
}

/**
* Retrieve a R2DBC object value for the specified item index (a column or an
* out-parameter).
* <p>The default implementation delegates to
* {@link #getItemValue(Readable, int, Class)}.
* @param readable is the {@code Row} or {@code OutParameters} holding the data
* @param itemIndex is the column index or out-parameter index
* @param pd the bean property that each result object is expected to match
* @return the Object value
* @see #getItemValue(Readable, int, Class)
*/
@Nullable
protected Object getItemValue(Readable readable, int itemIndex, PropertyDescriptor pd) {
return getItemValue(readable, itemIndex, pd.getPropertyType());
}

/**
* Retrieve a R2DBC object value for the specified item index (a column or
* an out-parameter).
* <p>The default implementation calls {@link Readable#get(int, Class)} then
* falls back to {@link Readable#get(int)} in case of an exception.
* Subclasses may override this to check specific value types upfront,
* or to post-process values return from {@code get}.
* @param readable is the {@code Row} or {@code OutParameters} holding the data
* @param itemIndex is the column index or out-parameter index
* @param paramType the target parameter type
* @return the Object value
* @see Readable#get(int, Class)
* @see Readable#get(int)
*/
@Nullable
protected Object getItemValue(Readable readable, int itemIndex, Class<?> paramType) {
try {
return readable.get(itemIndex, paramType);
}
catch (Throwable ex) {
return readable.get(itemIndex);
}
}


/**
* Static factory method to create a new {@code BeanPropertyRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @see #newInstance(Class, ConversionService)
*/
public static <T> BeanPropertyRowMapper<T> newInstance(Class<T> mappedClass) {
return new BeanPropertyRowMapper<>(mappedClass);
}

/**
* Static factory method to create a new {@code BeanPropertyRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @param conversionService the {@link ConversionService} for binding
* R2DBC values to bean properties, or {@code null} for none
* @see #newInstance(Class)
* @see #setConversionService
*/
public static <T> BeanPropertyRowMapper<T> newInstance(
Class<T> mappedClass, @Nullable ConversionService conversionService) {

BeanPropertyRowMapper<T> rowMapper = newInstance(mappedClass);
rowMapper.setConversionService(conversionService);
return rowMapper;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Copyright 2002-2023 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
*
* https://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.r2dbc.core;

import java.lang.reflect.Constructor;
import java.util.List;

import io.r2dbc.spi.Readable;
import io.r2dbc.spi.ReadableMetadata;

import org.springframework.beans.BeanUtils;
import org.springframework.beans.TypeConverter;
import org.springframework.core.MethodParameter;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

/**
* Mapping {@code Function} implementation that converts an R2DBC {@code Readable}
* (a {@code Row} or {@code OutParameters}) into a new instance of the specified mapped
* target class. The mapped target class must be a top-level class or {@code static}
* nested class, and it may expose either a <em>data class</em> constructor with named
* parameters corresponding to column names or classic bean property setter methods
* with property names corresponding to column names (or even a combination of both).
*
* <p>
* The term "data class" applies to Java <em>records</em>, Kotlin <em>data classes</em>,
* and any class which has a constructor with named parameters that are intended to be
* mapped to corresponding column names.
*
* <p>
* When combining a data class constructor with setter methods, any property mapped
* successfully via a constructor argument will not be mapped additionally via a
* corresponding setter method. This means that constructor arguments take precedence over
* property setter methods.
*
* <p>
* Note that this class extends {@link BeanPropertyRowMapper} and can therefore serve as a
* common choice for any mapped target class, flexibly adapting to constructor style
* versus setter methods in the mapped class.
*
* <p>
* Please note that this class is designed to provide convenience rather than high
* performance. For best performance, consider using a custom readable mapping
* {@code Function} implementation.
*
* @author Simon Baslé
* @since 6.1
* @param <T> the result type
*/
// Note: this class is adapted from the DataClassRowMapper in spring-jdbc
public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> {

@Nullable
private Constructor<T> mappedConstructor;

@Nullable
private String[] constructorParameterNames;

@Nullable
private TypeDescriptor[] constructorParameterTypes;


/**
* Create a new {@code DataClassRowMapper}.
* @param mappedClass the class that each row should be mapped to
*/
public DataClassRowMapper(Class<T> mappedClass) {
super(mappedClass);
}


@Override
protected void initialize(Class<T> mappedClass) {
super.initialize(mappedClass);

this.mappedConstructor = BeanUtils.getResolvableConstructor(mappedClass);
int paramCount = this.mappedConstructor.getParameterCount();
if (paramCount > 0) {
this.constructorParameterNames = BeanUtils.getParameterNames(this.mappedConstructor);
for (String name : this.constructorParameterNames) {
suppressProperty(name);
}
this.constructorParameterTypes = new TypeDescriptor[paramCount];
for (int i = 0; i < paramCount; i++) {
this.constructorParameterTypes[i] = new TypeDescriptor(new MethodParameter(this.mappedConstructor, i));
}
}
}

@Override
protected T constructMappedInstance(Readable readable, List<? extends ReadableMetadata> itemMetadatas, TypeConverter tc) {
Assert.state(this.mappedConstructor != null, "Mapped constructor was not initialized");

Object[] args;
if (this.constructorParameterNames != null && this.constructorParameterTypes != null) {
args = new Object[this.constructorParameterNames.length];
for (int i = 0; i < args.length; i++) {
String name = this.constructorParameterNames[i];
int index = findIndex(readable, itemMetadatas, lowerCaseName(name));
if (index == -1) {
index = findIndex(readable, itemMetadatas, underscoreName(name));
}
if (index == -1) {
throw new DataRetrievalFailureException("Unable to map constructor parameter '" + name + "' to a column or out-parameter");
}
TypeDescriptor td = this.constructorParameterTypes[i];
Object value = getItemValue(readable, index, td.getType());
args[i] = tc.convertIfNecessary(value, td.getType(), td);
}
}
else {
args = new Object[0];
}

return BeanUtils.instantiateClass(this.mappedConstructor, args);
}

private int findIndex(Readable readable, List<? extends ReadableMetadata> itemMetadatas, String name) {
int index = 0;
for (ReadableMetadata itemMetadata : itemMetadatas) {
//we use equalsIgnoreCase, similarly to RowMetadata#contains(String)
if (itemMetadata.getName().equalsIgnoreCase(name)) {
return index;
}
index++;
}
return -1;
}


/**
* Static factory method to create a new {@code DataClassRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @see #newInstance(Class, ConversionService)
*/
public static <T> DataClassRowMapper<T> newInstance(Class<T> mappedClass) {
return new DataClassRowMapper<>(mappedClass);
}

/**
* Static factory method to create a new {@code DataClassRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @param conversionService the {@link ConversionService} for binding
* R2DBC values to bean properties, or {@code null} for none
* @see #newInstance(Class)
* @see #setConversionService
*/
public static <T> DataClassRowMapper<T> newInstance(
Class<T> mappedClass, @Nullable ConversionService conversionService) {

DataClassRowMapper<T> rowMapper = newInstance(mappedClass);
rowMapper.setConversionService(conversionService);
return rowMapper;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/*
* Copyright 2002-2023 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
*
* https://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.r2dbc.core;

import io.r2dbc.spi.Readable;
import io.r2dbc.spi.test.MockColumnMetadata;
import io.r2dbc.spi.test.MockOutParameters;
import io.r2dbc.spi.test.MockRow;
import io.r2dbc.spi.test.MockRowMetadata;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.Mockito;

import org.springframework.beans.TypeMismatchException;
import org.springframework.dao.InvalidDataAccessApiUsageException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatNoException;

class BeanPropertyRowMapperTests {

@Test
void mappingUnknownReadableRejected() {
final BeanPropertyRowMapper<Person> mapper = new BeanPropertyRowMapper<>(Person.class);
assertThatIllegalArgumentException().isThrownBy(() -> mapper.apply(Mockito.mock(Readable.class)))
.withMessageStartingWith("Can only map Readable Row or OutParameters, got io.r2dbc.spi.Readable$MockitoMock$");
}

@Test
void mappingOutParametersAccepted() {
final BeanPropertyRowMapper<Person> mapper = new BeanPropertyRowMapper<>(Person.class);
assertThatNoException().isThrownBy(() -> mapper.apply(MockOutParameters.empty()));
}

@Test
void mappingRowSimpleObject() {
MockRow mockRow = SIMPLE_PERSON_ROW;
final BeanPropertyRowMapper<Person> mapper = new BeanPropertyRowMapper<>(Person.class);

Person result = mapper.apply(mockRow);

assertThat(result.firstName).as("firstName").isEqualTo("John");
assertThat(result.lastName).as("lastName").isEqualTo("Doe");
assertThat(result.age).as("age").isEqualTo(30);
}

@Test
void mappingRowMissingAttributeAccepted() {
MockRow mockRow = SIMPLE_PERSON_ROW;
final BeanPropertyRowMapper<ExtendedPerson> mapper = new BeanPropertyRowMapper<>(ExtendedPerson.class);

ExtendedPerson result = mapper.apply(mockRow);

assertThat(result.firstName).as("firstName").isEqualTo("John");
assertThat(result.lastName).as("lastName").isEqualTo("Doe");
assertThat(result.age).as("age").isEqualTo(30);
assertThat(result.address).as("address").isNull();
}

@Test
void mappingRowWithDifferentName() {
MockRow mockRow = EMAIL_PERSON_ROW;
final BeanPropertyRowMapper<EmailPerson> mapper = new BeanPropertyRowMapper<>(EmailPerson.class);

EmailPerson result = mapper.apply(mockRow);

assertThat(result.firstName).as("firstName").isEqualTo("John");
assertThat(result.lastName).as("lastName").isEqualTo("Doe");
assertThat(result.age).as("age").isEqualTo(30);
assertThat(result.email).as("email").isEqualTo("mail@example.org");
}

@Test
void mappingRowMissingAttributeRejected() {
MockRow mockRow = SIMPLE_PERSON_ROW;
final BeanPropertyRowMapper<ExtendedPerson> mapper = new BeanPropertyRowMapper<>(ExtendedPerson.class, true);

assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> mapper.apply(mockRow))
.withMessage("Given readable does not contain all items necessary to populate object of class org.springframework."
+ "r2dbc.core.BeanPropertyRowMapperTests$ExtendedPerson: [firstName, lastName, address, age]");
}

//TODO cannot trigger a mapping of a read-only property, as mappedProperties don't include properties without a setter.

@Test
void rowTypeAndMappingTypeMisaligned() {
MockRow mockRow = EXTENDED_PERSON_ROW;
final BeanPropertyRowMapper<TypeMismatchExtendedPerson> mapper = new BeanPropertyRowMapper<>(TypeMismatchExtendedPerson.class);

assertThatExceptionOfType(TypeMismatchException.class)
.isThrownBy(() -> mapper.apply(mockRow))
.withMessage("Failed to convert property value of type 'java.lang.String' to required type "
+ "'java.lang.String' for property 'address'; simulating type mismatch for address");
}

@Test
void usePrimitiveDefaultWithNullValueFromRow() {
MockRow mockRow = MockRow.builder()
.metadata(MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("firstName").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("lastName").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("age").javaType(Integer.class).build())
.build())
.identified(0, String.class, "John")
.identified(1, String.class, "Doe")
.identified(2, int.class, null)
.identified(3, String.class, "123 Sesame Street")
.build();
final BeanPropertyRowMapper<Person> mapper = new BeanPropertyRowMapper<>(Person.class);
mapper.setPrimitivesDefaultedForNullValue(true);

Person result = mapper.apply(mockRow);

assertThat(result.getAge()).isZero();
}

@ParameterizedTest
@CsvSource({
"age, age",
"lastName, last_name",
"Name, name",
"FirstName, first_name",
"EMail, e_mail",
"URL, u_r_l", // likely undesirable, but that's the status quo
})
void underscoreName(String input, String expected) {
BeanPropertyRowMapper<?> mapper = new BeanPropertyRowMapper<>(Object.class);
assertThat(mapper.underscoreName(input)).isEqualTo(expected);
}



private static class Person {

String firstName;
String lastName;
int age;

public String getFirstName() {
return this.firstName;
}

public void setFirstName(String firstName) {
this.firstName = firstName;
}

public String getLastName() {
return this.lastName;
}

public void setLastName(String lastName) {
this.lastName = lastName;
}

public int getAge() {
return this.age;
}

public void setAge(int age) {
this.age = age;
}
}

private static class ExtendedPerson extends Person {

String address;

public String getAddress() {
return this.address;
}

public void setAddress(String address) {
this.address = address;
}
}

private static class TypeMismatchExtendedPerson extends ExtendedPerson {

@Override
public void setAddress(String address) {
throw new ClassCastException("simulating type mismatch for address");
}
}

private static class EmailPerson extends Person {

String email;

public String getEmail() {
return this.email;
}

public void setEmail(String email) {
this.email = email;
}
}

private static final MockRow SIMPLE_PERSON_ROW = MockRow.builder()
.metadata(MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("firstName").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("lastName").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("age").javaType(Integer.class).build())
.build())
.identified(0, String.class, "John")
.identified(1, String.class, "Doe")
.identified(2, int.class, 30)
.build();

private static final MockRow EXTENDED_PERSON_ROW = MockRow.builder()
.metadata(MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("firstName").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("lastName").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("age").javaType(Integer.class).build())
.columnMetadata(MockColumnMetadata.builder().name("address").javaType(String.class).build())
.build())
.identified(0, String.class, "John")
.identified(1, String.class, "Doe")
.identified(2, int.class, 30)
.identified(3, String.class, "123 Sesame Street")
.build();

private static final MockRow EMAIL_PERSON_ROW = buildRowWithExtraColum("EMail", String.class,
String.class, "mail@example.org");

private static final MockRow buildRowWithExtraColum(String extraColumnName, Class<?> extraColumnClass, Class<?> identifiedClass, Object value) {
return MockRow.builder()
.metadata(MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("firstName").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("last_name").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("age").javaType(Integer.class).build())
.columnMetadata(MockColumnMetadata.builder().name(extraColumnName).javaType(extraColumnClass).build())
.build())
.identified(0, String.class, "John")
.identified(1, String.class, "Doe")
.identified(2, int.class, 30)
.identified(3, identifiedClass, value)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* Copyright 2002-2023 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
*
* https://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.r2dbc.core;

import java.math.BigDecimal;
import java.util.Date;
import java.util.List;

import io.r2dbc.spi.test.MockColumnMetadata;
import io.r2dbc.spi.test.MockRow;
import io.r2dbc.spi.test.MockRowMetadata;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class DataClassRowMapperTests {

@Test
void staticQueryWithDataClass() {
MockRow mockRow = MOCK_ROW; // uses name, age, birth_date
final DataClassRowMapper<ConstructorPerson> mapper = new DataClassRowMapper<>(ConstructorPerson.class);

ConstructorPerson person = mapper.apply(mockRow);

assertThat(person.name).as("name").isEqualTo("Bubba");
assertThat(person.age).as("age").isEqualTo(22L);
assertThat(person.birth_date).as("birth_date").isNotNull();
}

@Test
void staticQueryWithDataClassAndGenerics() {
MockRow mockRow = buildMockRow("birth_date", true); // uses name, age, birth_date, balance (as list)
//TODO validate actual R2DBC Row implementations would return something for balance if asking a List
final DataClassRowMapper<ConstructorPersonWithGenerics> mapper = new DataClassRowMapper<>(ConstructorPersonWithGenerics.class);
ConstructorPersonWithGenerics person = mapper.apply(mockRow);

assertThat(person.name()).isEqualTo("Bubba");
assertThat(person.age()).isEqualTo(22L);
assertThat(person.birth_date()).usingComparator(Date::compareTo).isEqualTo(new Date(1221222L));
assertThat(person.balance()).containsExactly(new BigDecimal("1234.56"));
}

@Test
void staticQueryWithDataRecord() {
MockRow mockRow = MOCK_ROW; // uses name, age, birth_date, balance
final DataClassRowMapper<RecordPerson> mapper = new DataClassRowMapper<>(RecordPerson.class);
RecordPerson person = mapper.apply(mockRow);

assertThat(person.name()).isEqualTo("Bubba");
assertThat(person.age()).isEqualTo(22L);
assertThat(person.birth_date()).usingComparator(Date::compareTo).isEqualTo(new Date(1221222L));
assertThat(person.balance()).isEqualTo(new BigDecimal("1234.56"));
}

@Test
void staticQueryWithDataClassAndSetters() {
MockRow mockRow = buildMockRow("birthdate", false); // uses name, age, birthdate (no underscore), balance
final DataClassRowMapper<ConstructorPersonWithSetters> mapper = new DataClassRowMapper<>(ConstructorPersonWithSetters.class);
ConstructorPersonWithSetters person = mapper.apply(mockRow);

assertThat(person.name()).isEqualTo("BUBBA");
assertThat(person.age()).isEqualTo(22L);
assertThat(person.birthDate()).usingComparator(Date::compareTo).isEqualTo(new Date(1221222L));
assertThat(person.balance()).isEqualTo(new BigDecimal("1234.56"));
}

static class ConstructorPerson {

final String name;

final long age;

final Date birth_date;

public ConstructorPerson(String name, long age, Date birth_date) {
this.name = name;
this.age = age;
this.birth_date = birth_date;
}

public String name() {
return this.name;
}

public long age() {
return this.age;
}

public Date birth_date() {
return this.birth_date;
}
}

static class ConstructorPersonWithGenerics extends ConstructorPerson {

private final List<BigDecimal> balance;

public ConstructorPersonWithGenerics(String name, long age, Date birth_date, List<BigDecimal> balance) {
super(name, age, birth_date);
this.balance = balance;
}

public List<BigDecimal> balance() {
return this.balance;
}
}

static class ConstructorPersonWithSetters {

private String name;

private long age;

private Date birthDate;

private BigDecimal balance;


public ConstructorPersonWithSetters(String name, long age, Date birthDate, BigDecimal balance) {
this.name = name.toUpperCase();
this.age = age;
this.birthDate = birthDate;
this.balance = balance;
}


public void setName(String name) {
this.name = name;
}

public void setAge(long age) {
this.age = age;
}

public void setBirthDate(Date birthDate) {
this.birthDate = birthDate;
}

public void setBalance(BigDecimal balance) {
this.balance = balance;
}

public String name() {
return this.name;
}

public long age() {
return this.age;
}

public Date birthDate() {
return this.birthDate;
}

public BigDecimal balance() {
return this.balance;
}
}

static record RecordPerson(String name, long age, Date birth_date, BigDecimal balance) {
}

static MockRow MOCK_ROW = buildMockRow("birth_date", false);

private static MockRow buildMockRow(String birthDateColumnName, boolean balanceObjectIdentifier) {
final MockRow.Builder builder = MockRow.builder();
builder.metadata(MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("name").javaType(String.class).build())
.columnMetadata(MockColumnMetadata.builder().name("age").javaType(long.class).build())
.columnMetadata(MockColumnMetadata.builder().name(birthDateColumnName).javaType(Date.class).build())
.columnMetadata(MockColumnMetadata.builder().name("balance").javaType(BigDecimal.class).build())
.build())
.identified(0, String.class, "Bubba")
.identified(1, long.class, 22)
.identified(2, Date.class, new Date(1221222L))
.identified(3, BigDecimal.class, new BigDecimal("1234.56"));
if (balanceObjectIdentifier) {
builder.identified(3, Object.class, new BigDecimal("1234.56"));
}
return builder.build();
}

}