From ae3bc378d650daa70c2f901a511ad8c96b4f6faf Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 15 Aug 2023 22:42:45 +0200 Subject: [PATCH] Support for parameter/result records and beans on DatabaseClient Includes a revision of BeanProperty/DataClassRowMapper with exclusively constructor-based configuration and without JDBC-inherited legacy settings. Closes gh-27282 Closes gh-26021 --- .../r2dbc/core/BeanPropertyRowMapper.java | 253 ++---------------- .../r2dbc/core/DataClassRowMapper.java | 101 +++---- .../r2dbc/core/DatabaseClient.java | 36 ++- .../r2dbc/core/DefaultDatabaseClient.java | 55 +++- ...bstractDatabaseClientIntegrationTests.java | 27 ++ .../core/DefaultDatabaseClientUnitTests.java | 105 ++++++-- .../core/R2dbcBeanPropertyRowMapperTests.java | 38 +-- .../core/R2dbcDataClassRowMapperTests.java | 2 + 8 files changed, 232 insertions(+), 385 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 eeedcf6cd5e0..fbae6633ad89 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 @@ -18,11 +18,9 @@ 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; @@ -31,20 +29,14 @@ 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; /** @@ -68,14 +60,6 @@ * {@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. @@ -85,147 +69,44 @@ * implementation. * * @author Simon Baslé - * @author Thomas Risberg * @author Juergen Hoeller * @author Sam Brannen * @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. */ - protected final Log logger = LogFactory.getLog(getClass()); - /** The class we are mapping to. */ - @Nullable - private Class mappedClass; + private final 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(); + /** ConversionService for binding result values to bean properties. */ + private final ConversionService conversionService; /** Map of the properties we provide mapping for. */ - @Nullable - private Map mappedProperties; + private final 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/outParameters should be mapped to + * Create a new {@code BeanPropertyRowMapper}. + * @param mappedClass the class that each row should be mapped to */ public BeanPropertyRowMapper(Class mappedClass) { - initialize(mappedClass); + this(mappedClass, DefaultConversionService.getSharedInstance()); } /** * 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 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 or out-parameters. - *

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. + * @param conversionService a {@link ConversionService} for binding + * result values to bean properties */ - 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. - *

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) { + public BeanPropertyRowMapper(Class mappedClass, ConversionService conversionService) { + Assert.notNull(mappedClass, "Mapped Class must not be null"); + Assert.notNull(conversionService, "ConversionService must not be null"); this.mappedClass = mappedClass; + this.conversionService = conversionService; this.mappedProperties = new HashMap<>(); - this.mappedPropertyNames = new HashSet<>(); for (PropertyDescriptor pd : BeanUtils.getPropertyDescriptors(mappedClass)) { if (pd.getWriteMethod() != null) { @@ -235,20 +116,18 @@ protected void initialize(Class mappedClass) { 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)); - } + this.mappedProperties.remove(lowerCaseName(propertyName)); + this.mappedProperties.remove(underscoreName(propertyName)); } /** @@ -309,52 +188,22 @@ public T apply(Readable readable) { private T mapForReadable(R readable, List readableMetadatas) { BeanWrapperImpl bw = new BeanWrapperImpl(); - initBeanWrapper(bw); - + bw.setConversionService(this.conversionService); T mappedObject = constructMappedInstance(readable, readableMetadatas, bw); bw.setBeanInstance(mappedObject); - Set populatedProperties = (isCheckFullyPopulated() ? new HashSet<>() : null); int readableItemCount = readableMetadatas.size(); - for(int itemIndex = 0; itemIndex < readableItemCount; itemIndex++) { + 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); + PropertyDescriptor pd = this.mappedProperties.get(property); 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 && isPrimitivesDefaultedForNullValue()) { - 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()); - } + Object value = getItemValue(readable, itemIndex, pd.getPropertyType()); + bw.setPropertyValue(pd.getName(), value); } } - 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; } @@ -369,43 +218,9 @@ private T mapForReadable(R 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 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 - * @see #getConversionService() - * @see BeanWrapper#setConversionService - */ - protected void initBeanWrapper(BeanWrapper bw) { - ConversionService cs = getConversionService(); - if (cs != null) { - bw.setConversionService(cs); - } - } - - /** - * Retrieve an R2DBC object value for the specified item index (a column or an - * out-parameter). - *

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 an R2DBC object value for the specified item index (a column or * an out-parameter). @@ -430,30 +245,4 @@ protected Object getItemValue(Readable readable, int itemIndex, Class paramTy } } - - /** - * 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 index 40e53c3c222d..c0b6af51a79c 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 @@ -27,9 +27,8 @@ import org.springframework.core.MethodParameter; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.dao.DataRetrievalFailureException; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; /** * Mapping {@code Function} implementation that converts an R2DBC {@link Readable} @@ -63,17 +62,13 @@ * @since 6.1 * @param the result type */ -// Note: this class is adapted from the DataClassRowMapper in spring-jdbc public class DataClassRowMapper extends BeanPropertyRowMapper { - @Nullable - private Constructor mappedConstructor; + private final Constructor mappedConstructor; - @Nullable - private String[] constructorParameterNames; + private final String[] constructorParameterNames; - @Nullable - private TypeDescriptor[] constructorParameterTypes; + private final TypeDescriptor[] constructorParameterTypes; /** @@ -81,57 +76,47 @@ public class DataClassRowMapper extends BeanPropertyRowMapper { * @param mappedClass the class that each row should be mapped to */ public DataClassRowMapper(Class mappedClass) { - super(mappedClass); + this(mappedClass, DefaultConversionService.getSharedInstance()); } - - @Override - protected void initialize(Class mappedClass) { - super.initialize(mappedClass); + public DataClassRowMapper(Class mappedClass, ConversionService conversionService) { + super(mappedClass, conversionService); 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)); - } + this.constructorParameterNames = (paramCount > 0 ? + BeanUtils.getParameterNames(this.mappedConstructor) : new String[0]); + 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 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); + Object[] args = new Object[this.constructorParameterNames.length]; + for (int i = 0; i < args.length; i++) { + String name = this.constructorParameterNames[i]; + int index = findIndex(itemMetadatas, lowerCaseName(name)); + if (index == -1) { + index = findIndex(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 itemMetadatas, String name) { + private int findIndex(List itemMetadatas, String name) { int index = 0; for (ReadableMetadata itemMetadata : itemMetadatas) { // we use equalsIgnoreCase, similar to RowMetadata#contains(String) @@ -143,30 +128,4 @@ private int findIndex(Readable readable, List itemMe 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/main/java/org/springframework/r2dbc/core/DatabaseClient.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java index f0884d07f27f..49e221f6685a 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java @@ -55,6 +55,7 @@ * .first(); * * @author Mark Paluch + * @author Juergen Hoeller * @since 5.3 */ public interface DatabaseClient extends ConnectionAccessor { @@ -191,6 +192,14 @@ interface GenericExecuteSpec { */ GenericExecuteSpec bindNull(String name, Class type); + /** + * Bind the bean properties or record components from the given + * source object, registering each as a named parameter. + * @param source the source object (a JavaBean or record) + * @since 6.1 + */ + GenericExecuteSpec bindProperties(Object source); + /** * Add the given filter to the end of the filter chain. *

Filter functions are typically used to invoke methods on the Statement @@ -222,7 +231,7 @@ default GenericExecuteSpec filter(Function the result type - * @return a {@link FetchSpec} for configuration what to fetch + * @return a {@link RowsFetchSpec} for configuration what to fetch * @since 6.0 */ RowsFetchSpec map(Function mappingFunction); @@ -232,12 +241,33 @@ default GenericExecuteSpec filter(Function the result type - * @return a {@link FetchSpec} for configuration what to fetch + * @return a {@link RowsFetchSpec} for configuration what to fetch */ RowsFetchSpec map(BiFunction mappingFunction); /** - * Perform the SQL call and apply {@link BiFunction function} to the {@link Result}. + * Configure a mapping for values in the first column and enter the execution stage. + * @param mappedClass the target class (a database-supported value class) + * @param the result type + * @return a {@link RowsFetchSpec} for configuration what to fetch + * @since 6.1 + * @see Readable#get(int, Class) + */ + RowsFetchSpec mapValue(Class mappedClass); + + /** + * Configure a row mapper for the given mapped class and enter the execution stage. + * @param mappedClass the target class (a JavaBean or record) with properties to + * map to (bean properties or record components) + * @param the result type + * @return a {@link RowsFetchSpec} for configuration what to fetch + * @since 6.1 + * @see DataClassRowMapper + */ + RowsFetchSpec mapProperties(Class mappedClass); + + /** + * Perform the SQL call and apply {@link BiFunction function} to the {@link Result}. * @param mappingFunction a function that maps from {@link Result} into a result publisher * @param the result type * @return a {@link Flux} that emits mapped elements diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java index 8c75690eb191..56b28c5379c9 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java @@ -16,6 +16,7 @@ package org.springframework.r2dbc.core; +import java.beans.PropertyDescriptor; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -47,6 +48,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.beans.BeanUtils; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.lang.Nullable; import org.springframework.r2dbc.connection.ConnectionFactoryUtils; @@ -54,6 +56,7 @@ import org.springframework.r2dbc.core.binding.BindTarget; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; /** @@ -64,6 +67,7 @@ * @author Mingyuan Wu * @author Bogdan Ilchyshyn * @author Simon Baslé + * @author Juergen Hoeller * @since 5.3 * @see DatabaseClient#create(ConnectionFactory) */ @@ -310,6 +314,23 @@ public DefaultGenericExecuteSpec bindNull(String name, Class type) { return new DefaultGenericExecuteSpec(this.byIndex, byName, this.sqlSupplier, this.filterFunction); } + @Override + public DefaultGenericExecuteSpec bindProperties(Object source) { + assertNotPreparedOperation(); + Assert.notNull(source, "Parameter source must not be null"); + + Map byName = new LinkedHashMap<>(this.byName); + for (PropertyDescriptor pd : BeanUtils.getPropertyDescriptors(source.getClass())) { + if (pd.getReadMethod() != null && pd.getReadMethod().getDeclaringClass() != Object.class) { + ReflectionUtils.makeAccessible(pd.getReadMethod()); + Object value = ReflectionUtils.invokeMethod(pd.getReadMethod(), source); + byName.put(pd.getName(), (value != null ? Parameters.in(value) : Parameters.in(pd.getPropertyType()))); + } + } + + return new DefaultGenericExecuteSpec(this.byIndex, byName, this.sqlSupplier, this.filterFunction); + } + @Override public DefaultGenericExecuteSpec filter(StatementFilterFunction filter) { Assert.notNull(filter, "StatementFilterFunction must not be null"); @@ -329,6 +350,18 @@ public FetchSpec map(BiFunction mappingFunction) { return execute(this.sqlSupplier, result -> result.map(mappingFunction)); } + @Override + public RowsFetchSpec mapValue(Class mappedClass) { + Assert.notNull(mappedClass, "Mapped class must not be null"); + return execute(this.sqlSupplier, result -> result.map(row -> row.get(0, mappedClass))); + } + + @Override + public FetchSpec mapProperties(Class mappedClass) { + Assert.notNull(mappedClass, "Mapped class must not be null"); + return execute(this.sqlSupplier, result -> result.map(new DataClassRowMapper(mappedClass))); + } + @Override public Flux flatMap(Function> mappingFunction) { Assert.notNull(mappingFunction, "Mapping function must not be null"); @@ -392,24 +425,20 @@ private ResultFunction getResultFunction(Supplier sqlSupplier) { return statement; }; - return new ResultFunction(sqlSupplier, statementFunction, this.filterFunction, DefaultDatabaseClient.this.executeFunction); + return new ResultFunction(sqlSupplier, statementFunction, this.filterFunction, + DefaultDatabaseClient.this.executeFunction); } private FetchSpec execute(Supplier sqlSupplier, Function> resultAdapter) { ResultFunction resultHandler = getResultFunction(sqlSupplier); - - return new DefaultFetchSpec<>( - DefaultDatabaseClient.this, - resultHandler, - connection -> sumRowsUpdated(resultHandler, connection), - resultAdapter); + return new DefaultFetchSpec<>(DefaultDatabaseClient.this, resultHandler, + connection -> sumRowsUpdated(resultHandler, connection), resultAdapter); } private Flux flatMap(Supplier sqlSupplier, Function> mappingFunction) { ResultFunction resultHandler = getResultFunction(sqlSupplier); - ConnectionFunction> connectionFunction = new DelegateConnectionFunction<>(resultHandler, cx -> resultHandler - .apply(cx) - .flatMap(mappingFunction)); + ConnectionFunction> connectionFunction = new DelegateConnectionFunction<>(resultHandler, + cx -> resultHandler.apply(cx).flatMap(mappingFunction)); return inConnectionMany(connectionFunction); } @@ -448,8 +477,7 @@ private Parameter getParameter(Map remainderByName, private void assertNotPreparedOperation() { if (this.sqlSupplier instanceof PreparedOperation) { - throw new InvalidDataAccessApiUsageException( - "Cannot add bindings to a PreparedOperation"); + throw new InvalidDataAccessApiUsageException("Cannot add bindings to a PreparedOperation"); } } @@ -497,8 +525,7 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl return this.target; case "close": // Handle close method: suppress, not valid. - return Mono.error( - new UnsupportedOperationException("Close is not supported!")); + return Mono.error(new UnsupportedOperationException("Close is not supported!")); } // Invoke method on target Connection. diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java index 288afd855b3c..734fef3a506c 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java @@ -33,6 +33,7 @@ * * @author Mark Paluch * @author Mingyuan Wu + * @author Juergen Hoeller */ abstract class AbstractDatabaseClientIntegrationTests { @@ -92,6 +93,25 @@ public void executeInsert() { .verifyComplete(); } + @Test + public void executeInsertWithRecords() { + DatabaseClient databaseClient = DatabaseClient.create(connectionFactory); + + databaseClient.sql("INSERT INTO legoset (id, name, manual) VALUES(:id, :name, :manual)") + .bindProperties(new ParameterRecord(42055, "SCHAUFELRADBAGGER", null)) + .fetch().rowsUpdated() + .as(StepVerifier::create) + .expectNext(1L) + .verifyComplete(); + + databaseClient.sql("SELECT id FROM legoset") + .mapProperties(ResultRecord.class) + .first() + .as(StepVerifier::create) + .assertNext(actual -> assertThat(actual.id()).isEqualTo(42055)) + .verifyComplete(); + } + @Test public void shouldTranslateDuplicateKeyException() { DatabaseClient databaseClient = DatabaseClient.create(connectionFactory); @@ -147,4 +167,11 @@ public void shouldEmitGeneratedKey() { .verifyComplete(); } + + record ParameterRecord(int id, String name, Integer manual) { + } + + record ResultRecord(int id) { + } + } diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DefaultDatabaseClientUnitTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DefaultDatabaseClientUnitTests.java index 8ebf0f1a83ae..5afcb3670c00 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DefaultDatabaseClientUnitTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DefaultDatabaseClientUnitTests.java @@ -103,7 +103,7 @@ void shouldCloseConnectionOnlyOnce() { DefaultDatabaseClient databaseClient = (DefaultDatabaseClient) databaseClientBuilder.build(); Flux flux = databaseClient.inConnectionMany(connection -> Flux.empty()); - flux.subscribe(new CoreSubscriber() { + flux.subscribe(new CoreSubscriber<>() { Subscription subscription; @@ -136,13 +136,15 @@ void executeShouldBindNullValues() { DatabaseClient databaseClient = databaseClientBuilder.namedParameters(false).build(); - databaseClient.sql("SELECT * FROM table WHERE key = $1").bindNull(0, - String.class).then().as(StepVerifier::create).verifyComplete(); + databaseClient.sql("SELECT * FROM table WHERE key = $1") + .bindNull(0, String.class) + .then().as(StepVerifier::create).verifyComplete(); verify(statement).bind(0, Parameters.in(String.class)); - databaseClient.sql("SELECT * FROM table WHERE key = $1").bindNull("$1", - String.class).then().as(StepVerifier::create).verifyComplete(); + databaseClient.sql("SELECT * FROM table WHERE key = $1") + .bindNull("$1", String.class) + .then().as(StepVerifier::create).verifyComplete(); verify(statement).bind("$1", Parameters.in(String.class)); } @@ -153,15 +155,15 @@ void executeShouldBindSettableValues() { Statement statement = mockStatementFor("SELECT * FROM table WHERE key = $1"); DatabaseClient databaseClient = databaseClientBuilder.namedParameters(false).build(); - databaseClient.sql("SELECT * FROM table WHERE key = $1").bind(0, - Parameter.empty(String.class)).then().as( - StepVerifier::create).verifyComplete(); + databaseClient.sql("SELECT * FROM table WHERE key = $1") + .bind(0, Parameter.empty(String.class)) + .then().as(StepVerifier::create).verifyComplete(); verify(statement).bind(0, Parameters.in(String.class)); - databaseClient.sql("SELECT * FROM table WHERE key = $1").bind("$1", - Parameter.empty(String.class)).then().as( - StepVerifier::create).verifyComplete(); + databaseClient.sql("SELECT * FROM table WHERE key = $1") + .bind("$1", Parameter.empty(String.class)) + .then().as(StepVerifier::create).verifyComplete(); verify(statement).bind("$1", Parameters.in(String.class)); } @@ -171,8 +173,9 @@ void executeShouldBindNamedNullValues() { Statement statement = mockStatementFor("SELECT * FROM table WHERE key = $1"); DatabaseClient databaseClient = databaseClientBuilder.build(); - databaseClient.sql("SELECT * FROM table WHERE key = :key").bindNull("key", - String.class).then().as(StepVerifier::create).verifyComplete(); + databaseClient.sql("SELECT * FROM table WHERE key = :key") + .bindNull("key", String.class) + .then().as(StepVerifier::create).verifyComplete(); verify(statement).bind(0, Parameters.in(String.class)); } @@ -185,9 +188,9 @@ void executeShouldBindNamedValuesFromIndexes() { DatabaseClient databaseClient = databaseClientBuilder.build(); databaseClient.sql( - "SELECT id, name, manual FROM legoset WHERE name IN (:name)").bind(0, - Arrays.asList("unknown", "dunno", "other")).then().as( - StepVerifier::create).verifyComplete(); + "SELECT id, name, manual FROM legoset WHERE name IN (:name)") + .bind(0, Arrays.asList("unknown", "dunno", "other")) + .then().as(StepVerifier::create).verifyComplete(); verify(statement).bind(0, "unknown"); verify(statement).bind(1, "dunno"); @@ -207,8 +210,9 @@ void executeShouldBindValues() { verify(statement).bind(0, Parameters.in("foo")); - databaseClient.sql("SELECT * FROM table WHERE key = $1").bind("$1", - "foo").then().as(StepVerifier::create).verifyComplete(); + databaseClient.sql("SELECT * FROM table WHERE key = $1") + .bind("$1", "foo") + .then().as(StepVerifier::create).verifyComplete(); verify(statement).bind("$1", Parameters.in("foo")); } @@ -218,8 +222,33 @@ void executeShouldBindNamedValuesByIndex() { Statement statement = mockStatementFor("SELECT * FROM table WHERE key = $1"); DatabaseClient databaseClient = databaseClientBuilder.build(); - databaseClient.sql("SELECT * FROM table WHERE key = :key").bind("key", - "foo").then().as(StepVerifier::create).verifyComplete(); + databaseClient.sql("SELECT * FROM table WHERE key = :key") + .bind("key", "foo") + .then().as(StepVerifier::create).verifyComplete(); + + verify(statement).bind(0, Parameters.in("foo")); + } + + @Test + void executeShouldBindBeanByIndex() { + Statement statement = mockStatementFor("SELECT * FROM table WHERE key = $1"); + DatabaseClient databaseClient = databaseClientBuilder.build(); + + databaseClient.sql("SELECT * FROM table WHERE key = :key") + .bindProperties(new ParameterBean("foo")) + .then().as(StepVerifier::create).verifyComplete(); + + verify(statement).bind(0, Parameters.in("foo")); + } + + @Test + void executeShouldBindRecordByIndex() { + Statement statement = mockStatementFor("SELECT * FROM table WHERE key = $1"); + DatabaseClient databaseClient = databaseClientBuilder.build(); + + databaseClient.sql("SELECT * FROM table WHERE key = :key") + .bindProperties(new ParameterRecord("foo")) + .then().as(StepVerifier::create).verifyComplete(); verify(statement).bind(0, Parameters.in("foo")); } @@ -249,15 +278,15 @@ void selectShouldEmitFirstValue() { MockColumnMetadata.builder().name("name").javaType(String.class).build()).build(); MockResult result = MockResult.builder().row( - MockRow.builder().identified(0, Object.class, "Walter").metadata(metadata).build(), - MockRow.builder().identified(0, Object.class, "White").metadata(metadata).build() + MockRow.builder().identified(0, String.class, "Walter").metadata(metadata).build(), + MockRow.builder().identified(0, String.class, "White").metadata(metadata).build() ).build(); mockStatementFor("SELECT * FROM person", result); DatabaseClient databaseClient = databaseClientBuilder.build(); - databaseClient.sql("SELECT * FROM person").map(row -> row.get(0)) + databaseClient.sql("SELECT * FROM person").mapValue(String.class) .first() .as(StepVerifier::create) .expectNext("Walter") @@ -270,15 +299,15 @@ void selectShouldEmitAllValues() { MockColumnMetadata.builder().name("name").javaType(String.class).build()).build(); MockResult result = MockResult.builder().row( - MockRow.builder().identified(0, Object.class, "Walter").metadata(metadata).build(), - MockRow.builder().identified(0, Object.class, "White").metadata(metadata).build() + MockRow.builder().identified(0, String.class, "Walter").metadata(metadata).build(), + MockRow.builder().identified(0, String.class, "White").metadata(metadata).build() ).build(); mockStatementFor("SELECT * FROM person", result); DatabaseClient databaseClient = databaseClientBuilder.build(); - databaseClient.sql("SELECT * FROM person").map(row -> row.get(0)) + databaseClient.sql("SELECT * FROM person").mapValue(String.class) .all() .as(StepVerifier::create) .expectNext("Walter") @@ -292,15 +321,15 @@ void selectOneShouldFailWithException() { MockColumnMetadata.builder().name("name").javaType(String.class).build()).build(); MockResult result = MockResult.builder().row( - MockRow.builder().identified(0, Object.class, "Walter").metadata(metadata).build(), - MockRow.builder().identified(0, Object.class, "White").metadata(metadata).build() + MockRow.builder().identified(0, String.class, "Walter").metadata(metadata).build(), + MockRow.builder().identified(0, String.class, "White").metadata(metadata).build() ).build(); mockStatementFor("SELECT * FROM person", result); DatabaseClient databaseClient = databaseClientBuilder.build(); - databaseClient.sql("SELECT * FROM person").map(row -> row.get(0)) + databaseClient.sql("SELECT * FROM person").mapValue(String.class) .one() .as(StepVerifier::create) .verifyError(IncorrectResultSizeDataAccessException.class); @@ -469,4 +498,22 @@ private MockResult mockSingleColumnResult(@Nullable MockRow.Builder row) { return resultBuilder.build(); } + + static class ParameterBean { + + private final String key; + + public ParameterBean(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + } + + + record ParameterRecord(String key) { + } + } diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcBeanPropertyRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcBeanPropertyRowMapperTests.java index be8b0dab19f6..f8c3acaf5e59 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcBeanPropertyRowMapperTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcBeanPropertyRowMapperTests.java @@ -27,7 +27,6 @@ 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; @@ -37,6 +36,8 @@ /** * Tests for R2DBC-based {@link BeanPropertyRowMapper}. * + * @author Simon Baslé + * @author Juergen Hoeller * @since 6.1 */ class R2dbcBeanPropertyRowMapperTests { @@ -92,20 +93,6 @@ void mappingRowWithDifferentName() { assertThat(result.email).as("email").isEqualTo("mail@example.org"); } - @Test - void mappingRowMissingAttributeRejected() { - Class mappedClass = ExtendedPerson.class; - MockRow mockRow = SIMPLE_PERSON_ROW; - BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(mappedClass, true); - - assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) - .isThrownBy(() -> mapper.apply(mockRow)) - .withMessage("Given readable does not contain all items necessary to populate object of %s" - + ": [firstName, lastName, address, age]", mappedClass); - } - - // 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; @@ -117,27 +104,6 @@ void rowTypeAndMappingTypeMisaligned() { + "'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(); - BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(Person.class); - mapper.setPrimitivesDefaultedForNullValue(true); - - Person result = mapper.apply(mockRow); - - assertThat(result.getAge()).isZero(); - } - @ParameterizedTest @CsvSource({ "age, age", diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcDataClassRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcDataClassRowMapperTests.java index 6f94b886f4a2..74532a7e4b54 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcDataClassRowMapperTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcDataClassRowMapperTests.java @@ -30,6 +30,8 @@ /** * Test for R2DBC-based {@link DataClassRowMapper}. * + * @author Simon Baslé + * @author Juergen Hoeller * @since 6.1 */ class R2dbcDataClassRowMapperTests {