Port BeanPropertyRowMapper and DataClassRowMapper for r2dbc

This commit ports and adapts spring-jdbc's `BeanPropertyRowMapper` and
`DataClassRowMapper` to spring-r2dbc, allowing to `map` rows or
outParameters to object instances, data classes or records.

See gh-26021
Closes gh-30530
This commit is contained in:
Simon Baslé
2023-05-25 17:40:52 +02:00
committed by GitHub
parent 9332b3f690
commit 5977bb05ca
4 changed files with 1091 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}