Browse Source

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
pull/31063/head
Juergen Hoeller 2 years ago
parent
commit
ae3bc378d6
  1. 247
      spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java
  2. 73
      spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java
  3. 34
      spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java
  4. 55
      spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java
  5. 27
      spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java
  6. 105
      spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DefaultDatabaseClientUnitTests.java
  7. 38
      spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcBeanPropertyRowMapperTests.java
  8. 2
      spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcDataClassRowMapperTests.java

247
spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java

@ -18,11 +18,9 @@ package org.springframework.r2dbc.core; @@ -18,11 +18,9 @@ package org.springframework.r2dbc.core;
import java.beans.PropertyDescriptor;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import io.r2dbc.spi.OutParameters;
@ -31,20 +29,14 @@ import io.r2dbc.spi.Readable; @@ -31,20 +29,14 @@ import io.r2dbc.spi.Readable;
import io.r2dbc.spi.ReadableMetadata;
import io.r2dbc.spi.Row;
import io.r2dbc.spi.RowMetadata;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.TypeConverter;
import org.springframework.beans.TypeMismatchException;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
/**
@ -68,14 +60,6 @@ import org.springframework.util.StringUtils; @@ -68,14 +60,6 @@ import org.springframework.util.StringUtils;
* {@code "select fname as first_name from customer"}, where {@code first_name}
* can be mapped to a {@code setFirstName(String)} method in the target class.
*
* <p>For a {@code NULL} value read from the database, an attempt will be made to
* call the corresponding setter method with {@code null}, but in the case of
* Java primitives this will result in a {@link TypeMismatchException} by default.
* To ignore {@code NULL} database values for all primitive properties in the
* target class, set the {@code primitivesDefaultedForNullValue} flag to
* {@code true}. See {@link #setPrimitivesDefaultedForNullValue(boolean)} for
* details.
*
* <p>If you need to map to a target class which has a <em>data class</em> constructor
* &mdash; for example, a Java {@code record} or a Kotlin {@code data} class &mdash;
* use {@link DataClassRowMapper} instead.
@ -85,147 +69,44 @@ import org.springframework.util.StringUtils; @@ -85,147 +69,44 @@ import org.springframework.util.StringUtils;
* implementation.
*
* @author Simon Baslé
* @author Thomas Risberg
* @author Juergen Hoeller
* @author Sam Brannen
* @since 6.1
* @param <T> the result type
* @see DataClassRowMapper
*/
// Note: this class is adapted from the BeanPropertyRowMapper in spring-jdbc
public class BeanPropertyRowMapper<T> implements Function<Readable, T> {
/** Logger available to subclasses. */
protected final Log logger = LogFactory.getLog(getClass());
/** The class we are mapping to. */
@Nullable
private Class<T> mappedClass;
private final Class<T> mappedClass;
/** Whether we're strictly validating. */
private boolean checkFullyPopulated = false;
/**
* Whether {@code NULL} database values should be ignored for primitive
* properties in the target class.
* @see #setPrimitivesDefaultedForNullValue(boolean)
*/
private boolean primitivesDefaultedForNullValue = false;
/** ConversionService for binding R2DBC values to bean properties. */
@Nullable
private ConversionService conversionService = DefaultConversionService.getSharedInstance();
/** ConversionService for binding result values to bean properties. */
private final ConversionService conversionService;
/** Map of the properties we provide mapping for. */
@Nullable
private Map<String, PropertyDescriptor> mappedProperties;
private final Map<String, PropertyDescriptor> mappedProperties;
/** Set of bean property names we provide mapping for. */
@Nullable
private Set<String> mappedPropertyNames;
/**
* Create a new {@code BeanPropertyRowMapper}, accepting unpopulated
* properties in the target bean.
* @param mappedClass the class that each row/outParameters should be mapped to
* Create a new {@code BeanPropertyRowMapper}.
* @param mappedClass the class that each row should be mapped to
*/
public BeanPropertyRowMapper(Class<T> 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<T> mappedClass, boolean checkFullyPopulated) {
initialize(mappedClass);
this.checkFullyPopulated = checkFullyPopulated;
}
/**
* Get the class that we are mapping to.
*/
@Nullable
public final Class<T> getMappedClass() {
return this.mappedClass;
}
/**
* Set whether we're strictly validating that all bean properties have been mapped
* from corresponding database columns or out-parameters.
* <p>Default is {@code false}, accepting unpopulated properties in the target bean.
*/
public void setCheckFullyPopulated(boolean checkFullyPopulated) {
this.checkFullyPopulated = checkFullyPopulated;
}
/**
* Return whether we're strictly validating that all bean properties have been
* mapped from corresponding database columns or out-parameters.
*/
public boolean isCheckFullyPopulated() {
return this.checkFullyPopulated;
}
/**
* Set whether a {@code NULL} database column or out-parameter value should
* be ignored when mapping to a corresponding primitive property in the target class.
* <p>Default is {@code false}, throwing an exception when nulls are mapped
* to Java primitives.
* <p>If this flag is set to {@code true} and you use an <em>ignored</em>
* primitive property value from the mapped bean to update the database, the
* value in the database will be changed from {@code NULL} to the current value
* of that primitive property. That value may be the property's initial value
* (potentially Java's default value for the respective primitive type), or
* it may be some other value set for the property in the default constructor
* (or initialization block) or as a side effect of setting some other property
* in the mapped bean.
*/
public void setPrimitivesDefaultedForNullValue(boolean primitivesDefaultedForNullValue) {
this.primitivesDefaultedForNullValue = primitivesDefaultedForNullValue;
}
/**
* Get the value of the {@code primitivesDefaultedForNullValue} flag.
* @see #setPrimitivesDefaultedForNullValue(boolean)
*/
public boolean isPrimitivesDefaultedForNullValue() {
return this.primitivesDefaultedForNullValue;
}
/**
* Set a {@link ConversionService} for binding R2DBC values to bean properties,
* or {@code null} for none.
* <p>Default is a {@link DefaultConversionService}. This provides support for
* {@code java.time} conversion and other special types.
* @see #initBeanWrapper(BeanWrapper)
*/
public void setConversionService(@Nullable ConversionService conversionService) {
this.conversionService = conversionService;
}
/**
* Return a {@link ConversionService} for binding R2DBC values to bean properties,
* or {@code null} if none.
*/
@Nullable
public ConversionService getConversionService() {
return this.conversionService;
}
/**
* Initialize the mapping meta-data for the given class.
* @param mappedClass the mapped class
* @param conversionService a {@link ConversionService} for binding
* result values to bean properties
*/
protected void initialize(Class<T> mappedClass) {
public BeanPropertyRowMapper(Class<T> 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,21 +116,19 @@ public class BeanPropertyRowMapper<T> implements Function<Readable, T> { @@ -235,21 +116,19 @@ public class BeanPropertyRowMapper<T> implements Function<Readable, T> {
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.
@ -309,50 +188,20 @@ public class BeanPropertyRowMapper<T> implements Function<Readable, T> { @@ -309,50 +188,20 @@ public class BeanPropertyRowMapper<T> implements Function<Readable, T> {
private <R extends Readable> T mapForReadable(R readable, List<? extends ReadableMetadata> readableMetadatas) {
BeanWrapperImpl bw = new BeanWrapperImpl();
initBeanWrapper(bw);
bw.setConversionService(this.conversionService);
T mappedObject = constructMappedInstance(readable, readableMetadatas, bw);
bw.setBeanInstance(mappedObject);
Set<String> populatedProperties = (isCheckFullyPopulated() ? new HashSet<>() : null);
int readableItemCount = readableMetadatas.size();
for(int itemIndex = 0; itemIndex < readableItemCount; itemIndex++) {
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 {
Object value = getItemValue(readable, itemIndex, pd.getPropertyType());
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());
}
}
}
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 @@ public class BeanPropertyRowMapper<T> implements Function<Readable, T> { @@ -369,43 +218,9 @@ public class BeanPropertyRowMapper<T> implements Function<Readable, T> {
* @return a corresponding instance of the mapped class
*/
protected T constructMappedInstance(Readable readable, List<? extends ReadableMetadata> itemMetadatas, TypeConverter tc) {
Assert.state(this.mappedClass != null, "Mapped class was not specified");
return BeanUtils.instantiateClass(this.mappedClass);
}
/**
* Initialize the given BeanWrapper to be used for row mapping or outParameters
* mapping.
* <p>To be called for each Readable.
* <p>The default implementation applies the configured {@link ConversionService},
* if any. Can be overridden in subclasses.
* @param bw the BeanWrapper to initialize
* @see #getConversionService()
* @see BeanWrapper#setConversionService
*/
protected void initBeanWrapper(BeanWrapper bw) {
ConversionService cs = getConversionService();
if (cs != null) {
bw.setConversionService(cs);
}
}
/**
* Retrieve an R2DBC object value for the specified item index (a column or an
* out-parameter).
* <p>The default implementation delegates to
* {@link #getItemValue(Readable, int, Class)}.
* @param readable is the {@code Row} or {@code OutParameters} holding the data
* @param itemIndex is the column index or out-parameter index
* @param pd the bean property that each result object is expected to match
* @return the Object value
* @see #getItemValue(Readable, int, Class)
*/
@Nullable
protected Object getItemValue(Readable readable, int itemIndex, PropertyDescriptor pd) {
return getItemValue(readable, itemIndex, pd.getPropertyType());
}
/**
* Retrieve an R2DBC object value for the specified item index (a column or
* an out-parameter).
@ -430,30 +245,4 @@ public class BeanPropertyRowMapper<T> implements Function<Readable, T> { @@ -430,30 +245,4 @@ public class BeanPropertyRowMapper<T> implements Function<Readable, T> {
}
}
/**
* Static factory method to create a new {@code BeanPropertyRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @see #newInstance(Class, ConversionService)
*/
public static <T> BeanPropertyRowMapper<T> newInstance(Class<T> mappedClass) {
return new BeanPropertyRowMapper<>(mappedClass);
}
/**
* Static factory method to create a new {@code BeanPropertyRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @param conversionService the {@link ConversionService} for binding
* R2DBC values to bean properties, or {@code null} for none
* @see #newInstance(Class)
* @see #setConversionService
*/
public static <T> BeanPropertyRowMapper<T> newInstance(
Class<T> mappedClass, @Nullable ConversionService conversionService) {
BeanPropertyRowMapper<T> rowMapper = newInstance(mappedClass);
rowMapper.setConversionService(conversionService);
return rowMapper;
}
}

73
spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java

@ -27,9 +27,8 @@ import org.springframework.beans.TypeConverter; @@ -27,9 +27,8 @@ 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.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 @@ import org.springframework.util.Assert; @@ -63,17 +62,13 @@ import org.springframework.util.Assert;
* @since 6.1
* @param <T> the result type
*/
// Note: this class is adapted from the DataClassRowMapper in spring-jdbc
public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> {
@Nullable
private Constructor<T> mappedConstructor;
private final Constructor<T> mappedConstructor;
@Nullable
private String[] constructorParameterNames;
private final String[] constructorParameterNames;
@Nullable
private TypeDescriptor[] constructorParameterTypes;
private final TypeDescriptor[] constructorParameterTypes;
/**
@ -81,18 +76,16 @@ public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> { @@ -81,18 +76,16 @@ public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> {
* @param mappedClass the class that each row should be mapped to
*/
public DataClassRowMapper(Class<T> mappedClass) {
super(mappedClass);
this(mappedClass, DefaultConversionService.getSharedInstance());
}
@Override
protected void initialize(Class<T> mappedClass) {
super.initialize(mappedClass);
public DataClassRowMapper(Class<T> 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);
this.constructorParameterNames = (paramCount > 0 ?
BeanUtils.getParameterNames(this.mappedConstructor) : new String[0]);
for (String name : this.constructorParameterNames) {
suppressProperty(name);
}
@ -101,37 +94,29 @@ public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> { @@ -101,37 +94,29 @@ public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> {
this.constructorParameterTypes[i] = new TypeDescriptor(new MethodParameter(this.mappedConstructor, i));
}
}
}
@Override
protected T constructMappedInstance(Readable readable, List<? extends ReadableMetadata> itemMetadatas, TypeConverter tc) {
Assert.state(this.mappedConstructor != null, "Mapped constructor was not initialized");
Object[] args;
if (this.constructorParameterNames != null && this.constructorParameterTypes != null) {
args = new Object[this.constructorParameterNames.length];
Object[] 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));
int index = findIndex(itemMetadatas, lowerCaseName(name));
if (index == -1) {
index = findIndex(readable, itemMetadatas, underscoreName(name));
index = findIndex(itemMetadatas, underscoreName(name));
}
if (index == -1) {
throw new DataRetrievalFailureException("Unable to map constructor parameter '" + name + "' to a column or out-parameter");
throw new DataRetrievalFailureException(
"Unable to map constructor parameter '" + name + "' to a column or out-parameter");
}
TypeDescriptor td = this.constructorParameterTypes[i];
Object value = getItemValue(readable, index, td.getType());
args[i] = tc.convertIfNecessary(value, td.getType(), td);
}
}
else {
args = new Object[0];
}
return BeanUtils.instantiateClass(this.mappedConstructor, args);
}
private int findIndex(Readable readable, List<? extends ReadableMetadata> itemMetadatas, String name) {
private int findIndex(List<? extends ReadableMetadata> itemMetadatas, String name) {
int index = 0;
for (ReadableMetadata itemMetadata : itemMetadatas) {
// we use equalsIgnoreCase, similar to RowMetadata#contains(String)
@ -143,30 +128,4 @@ public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> { @@ -143,30 +128,4 @@ public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> {
return -1;
}
/**
* Static factory method to create a new {@code DataClassRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @see #newInstance(Class, ConversionService)
*/
public static <T> DataClassRowMapper<T> newInstance(Class<T> mappedClass) {
return new DataClassRowMapper<>(mappedClass);
}
/**
* Static factory method to create a new {@code DataClassRowMapper}.
* @param mappedClass the class that each row should be mapped to
* @param conversionService the {@link ConversionService} for binding
* R2DBC values to bean properties, or {@code null} for none
* @see #newInstance(Class)
* @see #setConversionService
*/
public static <T> DataClassRowMapper<T> newInstance(
Class<T> mappedClass, @Nullable ConversionService conversionService) {
DataClassRowMapper<T> rowMapper = newInstance(mappedClass);
rowMapper.setConversionService(conversionService);
return rowMapper;
}
}

34
spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java

@ -55,6 +55,7 @@ import org.springframework.util.Assert; @@ -55,6 +55,7 @@ import org.springframework.util.Assert;
* .first();</pre>
*
* @author Mark Paluch
* @author Juergen Hoeller
* @since 5.3
*/
public interface DatabaseClient extends ConnectionAccessor {
@ -191,6 +192,14 @@ public interface DatabaseClient extends ConnectionAccessor { @@ -191,6 +192,14 @@ public interface DatabaseClient extends ConnectionAccessor {
*/
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.
* <p>Filter functions are typically used to invoke methods on the Statement
@ -222,7 +231,7 @@ public interface DatabaseClient extends ConnectionAccessor { @@ -222,7 +231,7 @@ public interface DatabaseClient extends ConnectionAccessor {
* Configure a result mapping {@link Function function} and enter the execution stage.
* @param mappingFunction a function that maps from {@link Readable} to the result type
* @param <R> the result type
* @return a {@link FetchSpec} for configuration what to fetch
* @return a {@link RowsFetchSpec} for configuration what to fetch
* @since 6.0
*/
<R> RowsFetchSpec<R> map(Function<? super Readable, R> mappingFunction);
@ -232,10 +241,31 @@ public interface DatabaseClient extends ConnectionAccessor { @@ -232,10 +241,31 @@ public interface DatabaseClient extends ConnectionAccessor {
* @param mappingFunction a function that maps from {@link Row} and {@link RowMetadata}
* to the result type
* @param <R> the result type
* @return a {@link FetchSpec} for configuration what to fetch
* @return a {@link RowsFetchSpec} for configuration what to fetch
*/
<R> RowsFetchSpec<R> map(BiFunction<Row, RowMetadata, R> mappingFunction);
/**
* 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 <R> the result type
* @return a {@link RowsFetchSpec} for configuration what to fetch
* @since 6.1
* @see Readable#get(int, Class)
*/
<R> RowsFetchSpec<R> mapValue(Class<R> 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 <R> the result type
* @return a {@link RowsFetchSpec} for configuration what to fetch
* @since 6.1
* @see DataClassRowMapper
*/
<R> RowsFetchSpec<R> mapProperties(Class<R> 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

55
spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java

@ -16,6 +16,7 @@ @@ -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 org.reactivestreams.Publisher; @@ -47,6 +48,7 @@ import org.reactivestreams.Publisher;
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.BindMarkersFactory; @@ -54,6 +56,7 @@ import org.springframework.r2dbc.core.binding.BindMarkersFactory;
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 @@ import org.springframework.util.StringUtils; @@ -64,6 +67,7 @@ import org.springframework.util.StringUtils;
* @author Mingyuan Wu
* @author Bogdan Ilchyshyn
* @author Simon Baslé
* @author Juergen Hoeller
* @since 5.3
* @see DatabaseClient#create(ConnectionFactory)
*/
@ -310,6 +314,23 @@ final class DefaultDatabaseClient implements DatabaseClient { @@ -310,6 +314,23 @@ final class DefaultDatabaseClient implements DatabaseClient {
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<String, Parameter> 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 @@ final class DefaultDatabaseClient implements DatabaseClient { @@ -329,6 +350,18 @@ final class DefaultDatabaseClient implements DatabaseClient {
return execute(this.sqlSupplier, result -> result.map(mappingFunction));
}
@Override
public <R> RowsFetchSpec<R> mapValue(Class<R> mappedClass) {
Assert.notNull(mappedClass, "Mapped class must not be null");
return execute(this.sqlSupplier, result -> result.map(row -> row.get(0, mappedClass)));
}
@Override
public <R> FetchSpec<R> mapProperties(Class<R> mappedClass) {
Assert.notNull(mappedClass, "Mapped class must not be null");
return execute(this.sqlSupplier, result -> result.map(new DataClassRowMapper<R>(mappedClass)));
}
@Override
public <R> Flux<R> flatMap(Function<Result, Publisher<R>> mappingFunction) {
Assert.notNull(mappingFunction, "Mapping function must not be null");
@ -392,24 +425,20 @@ final class DefaultDatabaseClient implements DatabaseClient { @@ -392,24 +425,20 @@ final class DefaultDatabaseClient implements DatabaseClient {
return statement;
};
return new ResultFunction(sqlSupplier, statementFunction, this.filterFunction, DefaultDatabaseClient.this.executeFunction);
return new ResultFunction(sqlSupplier, statementFunction, this.filterFunction,
DefaultDatabaseClient.this.executeFunction);
}
private <T> FetchSpec<T> execute(Supplier<String> sqlSupplier, Function<Result, Publisher<T>> 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 <T> Flux<T> flatMap(Supplier<String> sqlSupplier, Function<Result, Publisher<T>> mappingFunction) {
ResultFunction resultHandler = getResultFunction(sqlSupplier);
ConnectionFunction<Flux<T>> connectionFunction = new DelegateConnectionFunction<>(resultHandler, cx -> resultHandler
.apply(cx)
.flatMap(mappingFunction));
ConnectionFunction<Flux<T>> connectionFunction = new DelegateConnectionFunction<>(resultHandler,
cx -> resultHandler.apply(cx).flatMap(mappingFunction));
return inConnectionMany(connectionFunction);
}
@ -448,8 +477,7 @@ final class DefaultDatabaseClient implements DatabaseClient { @@ -448,8 +477,7 @@ final class DefaultDatabaseClient implements DatabaseClient {
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 @@ final class DefaultDatabaseClient implements DatabaseClient { @@ -497,8 +525,7 @@ final class DefaultDatabaseClient implements DatabaseClient {
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.

27
spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java

@ -33,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; @@ -33,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*
* @author Mark Paluch
* @author Mingyuan Wu
* @author Juergen Hoeller
*/
abstract class AbstractDatabaseClientIntegrationTests {
@ -92,6 +93,25 @@ abstract class AbstractDatabaseClientIntegrationTests { @@ -92,6 +93,25 @@ abstract class AbstractDatabaseClientIntegrationTests {
.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 @@ abstract class AbstractDatabaseClientIntegrationTests { @@ -147,4 +167,11 @@ abstract class AbstractDatabaseClientIntegrationTests {
.verifyComplete();
}
record ParameterRecord(int id, String name, Integer manual) {
}
record ResultRecord(int id) {
}
}

105
spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DefaultDatabaseClientUnitTests.java

@ -103,7 +103,7 @@ class DefaultDatabaseClientUnitTests { @@ -103,7 +103,7 @@ class DefaultDatabaseClientUnitTests {
DefaultDatabaseClient databaseClient = (DefaultDatabaseClient) databaseClientBuilder.build();
Flux<Object> flux = databaseClient.inConnectionMany(connection -> Flux.empty());
flux.subscribe(new CoreSubscriber<Object>() {
flux.subscribe(new CoreSubscriber<>() {
Subscription subscription;
@ -136,13 +136,15 @@ class DefaultDatabaseClientUnitTests { @@ -136,13 +136,15 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -153,15 +155,15 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -171,8 +173,9 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -185,9 +188,9 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -207,8 +210,9 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -218,8 +222,33 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -249,15 +278,15 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -270,15 +299,15 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -292,15 +321,15 @@ class DefaultDatabaseClientUnitTests {
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 @@ class DefaultDatabaseClientUnitTests { @@ -469,4 +498,22 @@ class DefaultDatabaseClientUnitTests {
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) {
}
}

38
spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcBeanPropertyRowMapperTests.java

@ -27,7 +27,6 @@ import org.junit.jupiter.params.provider.CsvSource; @@ -27,7 +27,6 @@ import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.Mockito;
import org.springframework.beans.TypeMismatchException;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@ -37,6 +36,8 @@ import static org.assertj.core.api.Assertions.assertThatNoException; @@ -37,6 +36,8 @@ import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for R2DBC-based {@link BeanPropertyRowMapper}.
*
* @author Simon Baslé
* @author Juergen Hoeller
* @since 6.1
*/
class R2dbcBeanPropertyRowMapperTests {
@ -92,20 +93,6 @@ class R2dbcBeanPropertyRowMapperTests { @@ -92,20 +93,6 @@ class R2dbcBeanPropertyRowMapperTests {
assertThat(result.email).as("email").isEqualTo("mail@example.org");
}
@Test
void mappingRowMissingAttributeRejected() {
Class<ExtendedPerson> mappedClass = ExtendedPerson.class;
MockRow mockRow = SIMPLE_PERSON_ROW;
BeanPropertyRowMapper<ExtendedPerson> 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 @@ class R2dbcBeanPropertyRowMapperTests { @@ -117,27 +104,6 @@ class R2dbcBeanPropertyRowMapperTests {
+ "'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<Person> mapper = new BeanPropertyRowMapper<>(Person.class);
mapper.setPrimitivesDefaultedForNullValue(true);
Person result = mapper.apply(mockRow);
assertThat(result.getAge()).isZero();
}
@ParameterizedTest
@CsvSource({
"age, age",

2
spring-r2dbc/src/test/java/org/springframework/r2dbc/core/R2dbcDataClassRowMapperTests.java

@ -30,6 +30,8 @@ import static org.assertj.core.api.Assertions.assertThat; @@ -30,6 +30,8 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* Test for R2DBC-based {@link DataClassRowMapper}.
*
* @author Simon Baslé
* @author Juergen Hoeller
* @since 6.1
*/
class R2dbcDataClassRowMapperTests {

Loading…
Cancel
Save