From 65554659ad67c17fb02f2d08a00a19fca95d232d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Tue, 23 May 2023 18:35:04 +0200 Subject: [PATCH 1/5] Port BeanPropertyRowMapper and DataClassRowMapper for r2dbc See gh-26021 --- .../r2dbc/core/BeanPropertyRowMapper.java | 436 ++++++++++++++++++ .../r2dbc/core/DataClassRowMapper.java | 173 +++++++ .../core/BeanPropertyRowMapperTests.java | 241 ++++++++++ .../r2dbc/core/DataClassRowMapperTests.java | 197 ++++++++ 4 files changed, 1047 insertions(+) create mode 100644 spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java create mode 100644 spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java create mode 100644 spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java create mode 100644 spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java new file mode 100644 index 00000000000..e1a917d7f3c --- /dev/null +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java @@ -0,0 +1,436 @@ +/* + * 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.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; + +import io.r2dbc.spi.ColumnMetadata; +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.NotWritablePropertyException; +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.DataRetrievalFailureException; +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 BiFunction} implementation that converts a row 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. + * + *

Column values are mapped based on matching the column 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. + * + *

Mapping is provided for properties in the target class for many common types — + * for example: String, boolean, Boolean, byte, Byte, short, Short, int, Integer, + * long, Long, float, Float, double, Double, BigDecimal, {@code java.util.Date}, etc. + * + *

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. + * + *

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. + * + *

If you need to map to a target class which has a data class constructor + * — for example, a Java {@code record} or a Kotlin {@code data} class — + * use {@link DataClassRowMapper} instead. + * + *

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 Thomas Risberg + * @author Juergen Hoeller + * @author Sam Brannen + * @author Simon Baslé + * @since 6.1 + * @param the result type + * @see DataClassRowMapper + */ +public class BeanPropertyRowMapper implements BiFunction { + + /** Logger available to subclasses. */ + protected final Log logger = LogFactory.getLog(getClass()); + + /** The class we are mapping to. */ + @Nullable + private Class 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 mappedProperties; + + /** Set of bean property names we provide mapping for. */ + @Nullable + private Set mappedPropertyNames; + + /** + * Create a new {@code BeanPropertyRowMapper}, accepting unpopulated + * properties in the target bean. + * @param mappedClass the class that each row should be mapped to + */ + public BeanPropertyRowMapper(Class 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 + */ + public BeanPropertyRowMapper(Class mappedClass, boolean checkFullyPopulated) { + initialize(mappedClass); + this.checkFullyPopulated = checkFullyPopulated; + } + + + /** + * Get the class that we are mapping to. + */ + @Nullable + public final Class getMappedClass() { + return this.mappedClass; + } + + /** + * Set whether we're strictly validating that all bean properties have been mapped + * from corresponding database columns. + *

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. + */ + public boolean isCheckFullyPopulated() { + return this.checkFullyPopulated; + } + + /** + * Set whether a {@code NULL} database column value should be ignored when + * mapping to a corresponding primitive property in the target class. + *

Default is {@code false}, throwing an exception when nulls are mapped + * to Java primitives. + *

If this flag is set to {@code true} and you use an ignored + * 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. + *

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 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 all columns in the current row. + *

Utilizes public setters and row meta-data. + * @see RowMetadata + */ + @Override + public T apply(Row row, RowMetadata rowMetadata) { + BeanWrapperImpl bw = new BeanWrapperImpl(); + initBeanWrapper(bw); + + T mappedObject = constructMappedInstance(row, bw); + bw.setBeanInstance(mappedObject); + + Set populatedProperties = (isCheckFullyPopulated() ? new HashSet<>() : null); + int columnCount = rowMetadata.getColumnMetadatas().size(); + for(int columnIndex = 0; columnIndex < columnCount; columnIndex++) { + ColumnMetadata columnMetadata = rowMetadata.getColumnMetadata(columnIndex); + String column = columnMetadata.getName(); + String property = lowerCaseName(StringUtils.delete(column, " ")); + PropertyDescriptor pd = (this.mappedProperties != null ? this.mappedProperties.get(property) : null); + if (pd != null) { + Object value = getColumnValue(row, columnIndex, 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 column '%s' \ + with null value when setting property '%s' of type '%s' on object: %s" + """.formatted(column, 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 row does not contain all properties " + + "necessary to populate object of " + this.mappedClass + ": " + this.mappedPropertyNames); + } + + return mappedObject; + } + + /** + * Construct an instance of the mapped class for the current row. + *

The default implementation simply instantiates the mapped class. + * Can be overridden in subclasses. + * @param row the {@code Row} being mapped + * @param tc a TypeConverter with this row mapper's conversion service + * @return a corresponding instance of the mapped class + */ + protected T constructMappedInstance(Row row, 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. + * To be called for each row. + *

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 column. + *

The default implementation delegates to + * {@link #getColumnValue(Row, int, Class)}. + * @param row is the {@code Row} holding the data + * @param index is the column index + * @param pd the bean property that each result object is expected to match + * @return the Object value + * @see #getColumnValue(Row, int, Class) + */ + @Nullable + protected Object getColumnValue(Row row, int index, PropertyDescriptor pd) { + return getColumnValue(row, index, pd.getPropertyType()); + } + + /** + * Retrieve a R2DBC object value for the specified column. + *

The default implementation calls {@link Row#get(int, Class)} then + * falls back to {@link Row#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 row is the {@code Row} holding the data + * @param index is the column index + * @param paramType the target parameter type + * @return the Object value + * @see Row#get(int, Class) + * @see Row#get(int) + */ + @Nullable + protected Object getColumnValue(Row row, int index, Class paramType) { + try { + return row.get(index, paramType); + } + catch (Throwable e) { + return row.get(index); + } + } + + + /** + * 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 BeanPropertyRowMapper newInstance(Class 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 BeanPropertyRowMapper newInstance( + Class mappedClass, @Nullable ConversionService conversionService) { + + BeanPropertyRowMapper rowMapper = newInstance(mappedClass); + rowMapper.setConversionService(conversionService); + return rowMapper; + } + +} diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java new file mode 100644 index 00000000000..902f3ec4bcf --- /dev/null +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java @@ -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 io.r2dbc.spi.ColumnMetadata; +import io.r2dbc.spi.Row; + +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 BiFunction} implementation that converts a row 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 data class 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). + * + *

+ * The term "data class" applies to Java records, Kotlin data classes, + * and any class which has a constructor with named parameters that are intended to be + * mapped to corresponding column names. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * Please note that this class is designed to provide convenience rather than high + * performance. For best performance, consider using a custom row mapping {@code BiFunction} + * implementation. + * + * @author Juergen Hoeller + * @author Sam Brannen + * @author Simon Baslé + * @since 6.1 + * @param the result type + */ +public class DataClassRowMapper extends BeanPropertyRowMapper { + + @Nullable + private Constructor 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 mappedClass) { + super(mappedClass); + } + + + @Override + protected void initialize(Class 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(Row row, 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(row, lowerCaseName(name)); + if (index == -1) { + index = findIndex(row, underscoreName(name)); + } + if (index == -1) { + throw new DataRetrievalFailureException("Unable to map constructor parameter '" + name + "' to a column"); + } + TypeDescriptor td = this.constructorParameterTypes[i]; + Object value = getColumnValue(row, 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(Row row, String name) { + int index = 0; + for (ColumnMetadata columnMetadata : row.getMetadata().getColumnMetadatas()) { + //we use equalsIgnoreCase, similarly to RowMetadata#contains(String) + if (columnMetadata.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 DataClassRowMapper newInstance(Class 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 DataClassRowMapper newInstance( + Class mappedClass, @Nullable ConversionService conversionService) { + + DataClassRowMapper rowMapper = newInstance(mappedClass); + rowMapper.setConversionService(conversionService); + return rowMapper; + } + +} diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java new file mode 100644 index 00000000000..1d6447ee90b --- /dev/null +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java @@ -0,0 +1,241 @@ +/* + * 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.test.MockColumnMetadata; +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.springframework.beans.NotWritablePropertyException; +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; + +class BeanPropertyRowMapperTests { + + @Test + void mappingRowSimpleObject() { + MockRow mockRow = SIMPLE_PERSON_ROW; + final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(Person.class); + + Person result = mapper.apply(mockRow, mockRow.getMetadata()); + + 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 mapper = new BeanPropertyRowMapper<>(ExtendedPerson.class); + + ExtendedPerson result = mapper.apply(mockRow, mockRow.getMetadata()); + + 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 mapper = new BeanPropertyRowMapper<>(EmailPerson.class); + + EmailPerson result = mapper.apply(mockRow, mockRow.getMetadata()); + + 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 mapper = new BeanPropertyRowMapper<>(ExtendedPerson.class, true); + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> mapper.apply(mockRow, mockRow.getMetadata())) + .withMessage("Given row does not contain all properties 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 mapper = new BeanPropertyRowMapper<>(TypeMismatchExtendedPerson.class); + + assertThatExceptionOfType(TypeMismatchException.class) + .isThrownBy(() -> mapper.apply(mockRow, mockRow.getMetadata())) + .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 mapper = new BeanPropertyRowMapper<>(Person.class); + mapper.setPrimitivesDefaultedForNullValue(true); + + Person result = mapper.apply(mockRow, mockRow.getMetadata()); + + 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(); + } + +} diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java new file mode 100644 index 00000000000..2bf2172aefc --- /dev/null +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java @@ -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 mapper = new DataClassRowMapper<>(ConstructorPerson.class); + + ConstructorPerson person = mapper.apply(mockRow, mockRow.getMetadata()); + + 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 mapper = new DataClassRowMapper<>(ConstructorPersonWithGenerics.class); + ConstructorPersonWithGenerics person = mapper.apply(mockRow, mockRow.getMetadata()); + + 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 mapper = new DataClassRowMapper<>(RecordPerson.class); + RecordPerson person = mapper.apply(mockRow, mockRow.getMetadata()); + + 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 mapper = new DataClassRowMapper<>(ConstructorPersonWithSetters.class); + ConstructorPersonWithSetters person = mapper.apply(mockRow, mockRow.getMetadata()); + + 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 balance; + + public ConstructorPersonWithGenerics(String name, long age, Date birth_date, List balance) { + super(name, age, birth_date); + this.balance = balance; + } + + public List 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(); + } + +} \ No newline at end of file From 682525d08defde5ac563d3393b72bb874fe6b2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Wed, 24 May 2023 16:20:51 +0200 Subject: [PATCH 2/5] Support Readable (Row or OutParameters) explicitly extracting List --- .../core/ReactiveAdapterRegistry.java | 1 - .../r2dbc/core/BeanPropertyRowMapper.java | 181 ++++++++++-------- .../r2dbc/core/DataClassRowMapper.java | 37 ++-- .../core/BeanPropertyRowMapperTests.java | 35 +++- .../r2dbc/core/DataClassRowMapperTests.java | 8 +- 5 files changed, 155 insertions(+), 107 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/ReactiveAdapterRegistry.java b/spring-core/src/main/java/org/springframework/core/ReactiveAdapterRegistry.java index 50d8043294f..04df93e03f3 100644 --- a/spring-core/src/main/java/org/springframework/core/ReactiveAdapterRegistry.java +++ b/spring-core/src/main/java/org/springframework/core/ReactiveAdapterRegistry.java @@ -167,7 +167,6 @@ public ReactiveAdapter getAdapter(@Nullable Class reactiveType, @Nullable Obj return null; } - /** * Return a shared default {@code ReactiveAdapterRegistry} instance, * lazily building it once needed. diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java index e1a917d7f3c..3911c1e4933 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java @@ -19,12 +19,16 @@ 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.BiFunction; +import java.util.function.Function; -import io.r2dbc.spi.ColumnMetadata; +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; @@ -33,12 +37,10 @@ import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanWrapper; import org.springframework.beans.BeanWrapperImpl; -import org.springframework.beans.NotWritablePropertyException; 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.DataRetrievalFailureException; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -46,39 +48,44 @@ import org.springframework.util.StringUtils; /** - * Mapping {@code BiFunction} implementation that converts a row 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. + * 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. * - *

Column values are mapped based on matching the column 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. + *

+ * 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. * - *

Mapping is provided for properties in the target class for many common types — - * for example: String, boolean, Boolean, byte, Byte, short, Short, int, Integer, - * long, Long, float, Float, double, Double, BigDecimal, {@code java.util.Date}, etc. + *

+ * Mapping is provided for properties in the target class for many common types — + * for example: String, boolean, Boolean, byte, Byte, short, Short, int, Integer, long, + * Long, float, Float, double, Double, BigDecimal, {@code java.util.Date}, etc. * - *

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. + *

+ * 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. * - *

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. + *

+ * 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. * - *

If you need to map to a target class which has a data class constructor - * — for example, a Java {@code record} or a Kotlin {@code data} class — - * use {@link DataClassRowMapper} instead. + *

+ * If you need to map to a target class which has a data class constructor + * — for example, a Java {@code record} or a Kotlin {@code data} class — use + * {@link DataClassRowMapper} instead. * - *

Please note that this class is designed to provide convenience rather than - * high performance. For best performance, consider using a custom mapping function + *

+ * 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 Thomas Risberg @@ -89,7 +96,7 @@ * @param the result type * @see DataClassRowMapper */ -public class BeanPropertyRowMapper implements BiFunction { +public class BeanPropertyRowMapper implements Function { /** Logger available to subclasses. */ protected final Log logger = LogFactory.getLog(getClass()); @@ -123,7 +130,7 @@ public class BeanPropertyRowMapper implements BiFunction /** * Create a new {@code BeanPropertyRowMapper}, accepting unpopulated * properties in the target bean. - * @param mappedClass the class that each row should be mapped to + * @param mappedClass the class that each row/outParameters should be mapped to */ public BeanPropertyRowMapper(Class mappedClass) { initialize(mappedClass); @@ -133,7 +140,8 @@ public BeanPropertyRowMapper(Class 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 + * all bean properties have been mapped from corresponding database columns or + * out-parameters */ public BeanPropertyRowMapper(Class mappedClass, boolean checkFullyPopulated) { initialize(mappedClass); @@ -151,7 +159,7 @@ public final Class getMappedClass() { /** * Set whether we're strictly validating that all bean properties have been mapped - * from corresponding database columns. + * from corresponding database columns or out-parameters. *

Default is {@code false}, accepting unpopulated properties in the target bean. */ public void setCheckFullyPopulated(boolean checkFullyPopulated) { @@ -160,15 +168,15 @@ public void setCheckFullyPopulated(boolean checkFullyPopulated) { /** * Return whether we're strictly validating that all bean properties have been - * mapped from corresponding database columns. + * mapped from corresponding database columns or out-parameters. */ public boolean isCheckFullyPopulated() { return this.checkFullyPopulated; } /** - * Set whether a {@code NULL} database column value should be ignored when - * mapping to a corresponding primitive property in the target class. + * 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. *

Default is {@code false}, throwing an exception when nulls are mapped * to Java primitives. *

If this flag is set to {@code true} and you use an ignored @@ -282,29 +290,44 @@ protected String underscoreName(String name) { return result.toString(); } - /** - * Extract the values for all columns in the current row. - *

Utilizes public setters and row meta-data. + * 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}. + *

Utilizes public setters and derives meta-data from the + * concrete type. * @see RowMetadata + * @see OutParametersMetadata + * @throws UnsupportedOperationException in case the concrete type + * is neither {@code Row} nor {@code OutParameters} */ @Override - public T apply(Row row, RowMetadata rowMetadata) { + 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 T mapForReadable(R readable, List readableMetadatas) { BeanWrapperImpl bw = new BeanWrapperImpl(); initBeanWrapper(bw); - T mappedObject = constructMappedInstance(row, bw); + T mappedObject = constructMappedInstance(readable, readableMetadatas, bw); bw.setBeanInstance(mappedObject); Set populatedProperties = (isCheckFullyPopulated() ? new HashSet<>() : null); - int columnCount = rowMetadata.getColumnMetadatas().size(); - for(int columnIndex = 0; columnIndex < columnCount; columnIndex++) { - ColumnMetadata columnMetadata = rowMetadata.getColumnMetadata(columnIndex); - String column = columnMetadata.getName(); - String property = lowerCaseName(StringUtils.delete(column, " ")); + 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 = getColumnValue(row, columnIndex, pd); + 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. @@ -317,9 +340,9 @@ public T apply(Row row, RowMetadata rowMetadata) { String propertyType = ClassUtils.getQualifiedName(pd.getPropertyType()); //here too, we miss the rowNumber information logger.debug(""" - Ignoring intercepted TypeMismatchException for column '%s' \ + Ignoring intercepted TypeMismatchException for item '%s' \ with null value when setting property '%s' of type '%s' on object: %s" - """.formatted(column, pd.getName(), propertyType, mappedObject), ex); + """.formatted(itemName, pd.getName(), propertyType, mappedObject), ex); } } else { @@ -333,7 +356,7 @@ public T apply(Row row, RowMetadata rowMetadata) { } if (populatedProperties != null && !populatedProperties.equals(this.mappedPropertyNames)) { - throw new InvalidDataAccessApiUsageException("Given row does not contain all properties " + + throw new InvalidDataAccessApiUsageException("Given readable does not contain all items " + "necessary to populate object of " + this.mappedClass + ": " + this.mappedPropertyNames); } @@ -341,21 +364,25 @@ public T apply(Row row, RowMetadata rowMetadata) { } /** - * Construct an instance of the mapped class for the current row. - *

The default implementation simply instantiates the mapped class. - * Can be overridden in subclasses. - * @param row the {@code Row} being mapped + * Construct an instance of the mapped class for the current {@code Readable}. + *

+ * 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(Row row, TypeConverter tc) { + protected T constructMappedInstance(Readable readable, List 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. - * To be called for each row. + * Initialize the given BeanWrapper to be used for row mapping or outParameters + * mapping. + * To be called for each Readable. *

The default implementation applies the configured {@link ConversionService}, * if any. Can be overridden in subclasses. * @param bw the BeanWrapper to initialize @@ -370,40 +397,42 @@ protected void initBeanWrapper(BeanWrapper bw) { } /** - * Retrieve a R2DBC object value for the specified column. + * Retrieve a R2DBC object value for the specified item index (a column or an + * out-parameter). *

The default implementation delegates to - * {@link #getColumnValue(Row, int, Class)}. - * @param row is the {@code Row} holding the data - * @param index is the column index + * {@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 #getColumnValue(Row, int, Class) + * @see #getItemValue(Readable, int, Class) */ @Nullable - protected Object getColumnValue(Row row, int index, PropertyDescriptor pd) { - return getColumnValue(row, index, pd.getPropertyType()); + protected Object getItemValue(Readable readable, int itemIndex, PropertyDescriptor pd) { + return getItemValue(readable, itemIndex, pd.getPropertyType()); } /** - * Retrieve a R2DBC object value for the specified column. - *

The default implementation calls {@link Row#get(int, Class)} then - * falls back to {@link Row#get(int)} in case of an exception. + * Retrieve a R2DBC object value for the specified item index (a column or + * an out-parameter). + *

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 row is the {@code Row} holding the data - * @param index is the column index + * @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 Row#get(int, Class) - * @see Row#get(int) + * @see Readable#get(int, Class) + * @see Readable#get(int) */ @Nullable - protected Object getColumnValue(Row row, int index, Class paramType) { + protected Object getItemValue(Readable readable, int itemIndex, Class paramType) { try { - return row.get(index, paramType); + return readable.get(itemIndex, paramType); } catch (Throwable e) { - return row.get(index); + return readable.get(itemIndex); } } diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java index 902f3ec4bcf..f5f2c4da091 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java @@ -17,9 +17,10 @@ package org.springframework.r2dbc.core; import java.lang.reflect.Constructor; +import java.util.List; -import io.r2dbc.spi.ColumnMetadata; -import io.r2dbc.spi.Row; +import io.r2dbc.spi.Readable; +import io.r2dbc.spi.ReadableMetadata; import org.springframework.beans.BeanUtils; import org.springframework.beans.TypeConverter; @@ -31,12 +32,12 @@ import org.springframework.util.Assert; /** - * Mapping {@code BiFunction} implementation that converts a row 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 data class 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). + * 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 data class 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). * *

* The term "data class" applies to Java records, Kotlin data classes, @@ -56,8 +57,8 @@ * *

* Please note that this class is designed to provide convenience rather than high - * performance. For best performance, consider using a custom row mapping {@code BiFunction} - * implementation. + * performance. For best performance, consider using a custom readable mapping + * {@code Function} implementation. * * @author Juergen Hoeller * @author Sam Brannen @@ -105,7 +106,7 @@ protected void initialize(Class mappedClass) { } @Override - protected T constructMappedInstance(Row row, TypeConverter tc) { + protected T constructMappedInstance(Readable readable, List itemMetadatas, TypeConverter tc) { Assert.state(this.mappedConstructor != null, "Mapped constructor was not initialized"); Object[] args; @@ -113,15 +114,15 @@ protected T constructMappedInstance(Row row, TypeConverter tc) { args = new Object[this.constructorParameterNames.length]; for (int i = 0; i < args.length; i++) { String name = this.constructorParameterNames[i]; - int index = findIndex(row, lowerCaseName(name)); + int index = findIndex(readable, itemMetadatas, lowerCaseName(name)); if (index == -1) { - index = findIndex(row, underscoreName(name)); + index = findIndex(readable, itemMetadatas, underscoreName(name)); } if (index == -1) { - throw new DataRetrievalFailureException("Unable to map constructor parameter '" + name + "' to a column"); + throw new DataRetrievalFailureException("Unable to map constructor parameter '" + name + "' to a column or out-parameter"); } TypeDescriptor td = this.constructorParameterTypes[i]; - Object value = getColumnValue(row, index, td.getType()); + Object value = getItemValue(readable, index, td.getType()); args[i] = tc.convertIfNecessary(value, td.getType(), td); } } @@ -132,11 +133,11 @@ protected T constructMappedInstance(Row row, TypeConverter tc) { return BeanUtils.instantiateClass(this.mappedConstructor, args); } - private int findIndex(Row row, String name) { + private int findIndex(Readable readable, List itemMetadatas, String name) { int index = 0; - for (ColumnMetadata columnMetadata : row.getMetadata().getColumnMetadatas()) { + for (ReadableMetadata itemMetadata : itemMetadatas) { //we use equalsIgnoreCase, similarly to RowMetadata#contains(String) - if (columnMetadata.getName().equalsIgnoreCase(name)) { + if (itemMetadata.getName().equalsIgnoreCase(name)) { return index; } index++; diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java index 1d6447ee90b..babbc993504 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java @@ -16,28 +16,47 @@ package org.springframework.r2dbc.core; +import io.r2dbc.spi.OutParameters; +import io.r2dbc.spi.OutParametersMetadata; +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.NotWritablePropertyException; 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 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 mapper = new BeanPropertyRowMapper<>(Person.class); + assertThatNoException().isThrownBy(() -> mapper.apply(MockOutParameters.empty())); + } + @Test void mappingRowSimpleObject() { MockRow mockRow = SIMPLE_PERSON_ROW; final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(Person.class); - Person result = mapper.apply(mockRow, mockRow.getMetadata()); + Person result = mapper.apply(mockRow); assertThat(result.firstName).as("firstName").isEqualTo("John"); assertThat(result.lastName).as("lastName").isEqualTo("Doe"); @@ -49,7 +68,7 @@ void mappingRowMissingAttributeAccepted() { MockRow mockRow = SIMPLE_PERSON_ROW; final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(ExtendedPerson.class); - ExtendedPerson result = mapper.apply(mockRow, mockRow.getMetadata()); + ExtendedPerson result = mapper.apply(mockRow); assertThat(result.firstName).as("firstName").isEqualTo("John"); assertThat(result.lastName).as("lastName").isEqualTo("Doe"); @@ -62,7 +81,7 @@ void mappingRowWithDifferentName() { MockRow mockRow = EMAIL_PERSON_ROW; final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(EmailPerson.class); - EmailPerson result = mapper.apply(mockRow, mockRow.getMetadata()); + EmailPerson result = mapper.apply(mockRow); assertThat(result.firstName).as("firstName").isEqualTo("John"); assertThat(result.lastName).as("lastName").isEqualTo("Doe"); @@ -76,8 +95,8 @@ void mappingRowMissingAttributeRejected() { final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(ExtendedPerson.class, true); assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) - .isThrownBy(() -> mapper.apply(mockRow, mockRow.getMetadata())) - .withMessage("Given row does not contain all properties necessary to populate object of class org.springframework." + .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]"); } @@ -89,7 +108,7 @@ void rowTypeAndMappingTypeMisaligned() { final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(TypeMismatchExtendedPerson.class); assertThatExceptionOfType(TypeMismatchException.class) - .isThrownBy(() -> mapper.apply(mockRow, mockRow.getMetadata())) + .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"); } @@ -110,7 +129,7 @@ void usePrimitiveDefaultWithNullValueFromRow() { final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(Person.class); mapper.setPrimitivesDefaultedForNullValue(true); - Person result = mapper.apply(mockRow, mockRow.getMetadata()); + Person result = mapper.apply(mockRow); assertThat(result.getAge()).isZero(); } diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java index 2bf2172aefc..5cc4605fc7f 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java @@ -34,7 +34,7 @@ void staticQueryWithDataClass() { MockRow mockRow = MOCK_ROW; // uses name, age, birth_date final DataClassRowMapper mapper = new DataClassRowMapper<>(ConstructorPerson.class); - ConstructorPerson person = mapper.apply(mockRow, mockRow.getMetadata()); + ConstructorPerson person = mapper.apply(mockRow); assertThat(person.name).as("name").isEqualTo("Bubba"); assertThat(person.age).as("age").isEqualTo(22L); @@ -46,7 +46,7 @@ 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 mapper = new DataClassRowMapper<>(ConstructorPersonWithGenerics.class); - ConstructorPersonWithGenerics person = mapper.apply(mockRow, mockRow.getMetadata()); + ConstructorPersonWithGenerics person = mapper.apply(mockRow); assertThat(person.name()).isEqualTo("Bubba"); assertThat(person.age()).isEqualTo(22L); @@ -58,7 +58,7 @@ void staticQueryWithDataClassAndGenerics() { void staticQueryWithDataRecord() { MockRow mockRow = MOCK_ROW; // uses name, age, birth_date, balance final DataClassRowMapper mapper = new DataClassRowMapper<>(RecordPerson.class); - RecordPerson person = mapper.apply(mockRow, mockRow.getMetadata()); + RecordPerson person = mapper.apply(mockRow); assertThat(person.name()).isEqualTo("Bubba"); assertThat(person.age()).isEqualTo(22L); @@ -70,7 +70,7 @@ void staticQueryWithDataRecord() { void staticQueryWithDataClassAndSetters() { MockRow mockRow = buildMockRow("birthdate", false); // uses name, age, birthdate (no underscore), balance final DataClassRowMapper mapper = new DataClassRowMapper<>(ConstructorPersonWithSetters.class); - ConstructorPersonWithSetters person = mapper.apply(mockRow, mockRow.getMetadata()); + ConstructorPersonWithSetters person = mapper.apply(mockRow); assertThat(person.name()).isEqualTo("BUBBA"); assertThat(person.age()).isEqualTo(22L); From 721a07ce47e4fccfc9d1f751e46dfa2d7cc9d335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Wed, 24 May 2023 18:21:58 +0200 Subject: [PATCH 3/5] fix checkstyle issues --- .../springframework/r2dbc/core/BeanPropertyRowMapper.java | 6 +++--- .../r2dbc/core/BeanPropertyRowMapperTests.java | 2 -- .../springframework/r2dbc/core/DataClassRowMapperTests.java | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java index 3911c1e4933..612f791e9af 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java @@ -296,10 +296,10 @@ protected String underscoreName(String name) { * case of an {@code OutParameters}. *

Utilizes public setters and derives meta-data from the * concrete type. - * @see RowMetadata - * @see OutParametersMetadata * @throws UnsupportedOperationException in case the concrete type * is neither {@code Row} nor {@code OutParameters} + * @see RowMetadata + * @see OutParametersMetadata */ @Override public T apply(Readable readable) { @@ -431,7 +431,7 @@ protected Object getItemValue(Readable readable, int itemIndex, Class paramTy try { return readable.get(itemIndex, paramType); } - catch (Throwable e) { + catch (Throwable ex) { return readable.get(itemIndex); } } diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java index babbc993504..67a076f7f58 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java @@ -16,8 +16,6 @@ package org.springframework.r2dbc.core; -import io.r2dbc.spi.OutParameters; -import io.r2dbc.spi.OutParametersMetadata; import io.r2dbc.spi.Readable; import io.r2dbc.spi.test.MockColumnMetadata; import io.r2dbc.spi.test.MockOutParameters; diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java index 5cc4605fc7f..ecc9cb175f7 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java @@ -194,4 +194,4 @@ private static MockRow buildMockRow(String birthDateColumnName, boolean balanceO return builder.build(); } -} \ No newline at end of file +} From 391138c337c68d6e3016e1d95924bbbcf4e32752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Thu, 25 May 2023 17:10:41 +0200 Subject: [PATCH 4/5] revert formatting change in unrelated file --- .../java/org/springframework/core/ReactiveAdapterRegistry.java | 1 + 1 file changed, 1 insertion(+) diff --git a/spring-core/src/main/java/org/springframework/core/ReactiveAdapterRegistry.java b/spring-core/src/main/java/org/springframework/core/ReactiveAdapterRegistry.java index 04df93e03f3..50d8043294f 100644 --- a/spring-core/src/main/java/org/springframework/core/ReactiveAdapterRegistry.java +++ b/spring-core/src/main/java/org/springframework/core/ReactiveAdapterRegistry.java @@ -167,6 +167,7 @@ public ReactiveAdapter getAdapter(@Nullable Class reactiveType, @Nullable Obj return null; } + /** * Return a shared default {@code ReactiveAdapterRegistry} instance, * lazily building it once needed. From e84fa9d9645e89bdc843cc4b676259fd17e90262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Thu, 25 May 2023 17:37:10 +0200 Subject: [PATCH 5/5] polish authors: add attribution as a comment --- .../org/springframework/r2dbc/core/BeanPropertyRowMapper.java | 4 +--- .../org/springframework/r2dbc/core/DataClassRowMapper.java | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java index 612f791e9af..7636ce2e17e 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java @@ -88,14 +88,12 @@ * performance. For best performance, consider using a custom mapping function * implementation. * - * @author Thomas Risberg - * @author Juergen Hoeller - * @author Sam Brannen * @author Simon Baslé * @since 6.1 * @param the result type * @see DataClassRowMapper */ +// Note: this class is adapted from the BeanPropertyRowMapper in spring-jdbc public class BeanPropertyRowMapper implements Function { /** Logger available to subclasses. */ diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java index f5f2c4da091..87a67e73873 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java @@ -60,12 +60,11 @@ * performance. For best performance, consider using a custom readable mapping * {@code Function} implementation. * - * @author Juergen Hoeller - * @author Sam Brannen * @author Simon Baslé * @since 6.1 * @param the result type */ +// Note: this class is adapted from the DataClassRowMapper in spring-jdbc public class DataClassRowMapper extends BeanPropertyRowMapper { @Nullable