Add ConnectionDetail support to R2DBC auto-configuration

Update R2DBC auto-configuration so that `R2dbcConnectionDetails` beans
may be optionally used to provide connection details.

See gh-34657

Co-Authored-By: Mortitz Halbritter <mkammerer@vmware.com>
Co-Authored-By: Phillip Webb <pwebb@vmware.com>
This commit is contained in:
Andy Wilkinson 2023-03-23 23:23:00 -07:00
parent d09ac00824
commit 61e9fe8cd4
7 changed files with 231 additions and 59 deletions

View File

@ -237,6 +237,7 @@ dependencies {
testImplementation("org.mockito:mockito-core")
testImplementation("org.mockito:mockito-junit-jupiter")
testImplementation("org.postgresql:postgresql")
testImplementation("org.postgresql:r2dbc-postgresql")
testImplementation("org.skyscreamer:jsonassert")
testImplementation("org.springframework:spring-test")
testImplementation("org.springframework:spring-core-test")

View File

@ -53,7 +53,7 @@ class ConnectionFactoryBeanCreationFailureAnalyzer
private String getDescription(ConnectionFactoryBeanCreationException cause) {
StringBuilder description = new StringBuilder();
description.append("Failed to configure a ConnectionFactory: ");
if (!StringUtils.hasText(cause.getProperties().getUrl())) {
if (!StringUtils.hasText(cause.getUrl())) {
description.append("'url' attribute is not specified and ");
}
description.append(String.format("no embedded database could be configured.%n"));

View File

@ -51,14 +51,18 @@ import org.springframework.util.StringUtils;
* @author Mark Paluch
* @author Stephane Nicoll
* @author Rodolpho S. Couto
* @author Moritz Halbritter
* @author Andy Wilkinson
* @author Phillip Webb
*/
abstract class ConnectionFactoryConfigurations {
protected static ConnectionFactory createConnectionFactory(R2dbcProperties properties, ClassLoader classLoader,
protected static ConnectionFactory createConnectionFactory(R2dbcProperties properties,
R2dbcConnectionDetails connectionDetails, ClassLoader classLoader,
List<ConnectionFactoryOptionsBuilderCustomizer> optionsCustomizers) {
try {
return org.springframework.boot.r2dbc.ConnectionFactoryBuilder
.withOptions(new ConnectionFactoryOptionsInitializer().initialize(properties,
.withOptions(new ConnectionFactoryOptionsInitializer().initialize(properties, connectionDetails,
() -> EmbeddedDatabaseConnection.get(classLoader)))
.configure((options) -> {
for (ConnectionFactoryOptionsBuilderCustomizer optionsCustomizer : optionsCustomizers) {
@ -87,10 +91,12 @@ abstract class ConnectionFactoryConfigurations {
static class PooledConnectionFactoryConfiguration {
@Bean(destroyMethod = "dispose")
ConnectionPool connectionFactory(R2dbcProperties properties, ResourceLoader resourceLoader,
ConnectionPool connectionFactory(R2dbcProperties properties,
ObjectProvider<R2dbcConnectionDetails> connectionDetails, ResourceLoader resourceLoader,
ObjectProvider<ConnectionFactoryOptionsBuilderCustomizer> customizers) {
ConnectionFactory connectionFactory = createConnectionFactory(properties,
resourceLoader.getClassLoader(), customizers.orderedStream().toList());
connectionDetails.getIfAvailable(), resourceLoader.getClassLoader(),
customizers.orderedStream().toList());
R2dbcProperties.Pool pool = properties.getPool();
PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
ConnectionPoolConfiguration.Builder builder = ConnectionPoolConfiguration.builder(connectionFactory);
@ -116,10 +122,11 @@ abstract class ConnectionFactoryConfigurations {
static class GenericConfiguration {
@Bean
ConnectionFactory connectionFactory(R2dbcProperties properties, ResourceLoader resourceLoader,
ConnectionFactory connectionFactory(R2dbcProperties properties,
ObjectProvider<R2dbcConnectionDetails> connectionDetails, ResourceLoader resourceLoader,
ObjectProvider<ConnectionFactoryOptionsBuilderCustomizer> customizers) {
return createConnectionFactory(properties, resourceLoader.getClassLoader(),
customizers.orderedStream().toList());
return createConnectionFactory(properties, connectionDetails.getIfAvailable(),
resourceLoader.getClassLoader(), customizers.orderedStream().toList());
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-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.
@ -16,12 +16,10 @@
package org.springframework.boot.autoconfigure.r2dbc;
import java.util.function.Predicate;
import java.util.function.Supplier;
import io.r2dbc.spi.ConnectionFactoryOptions;
import io.r2dbc.spi.ConnectionFactoryOptions.Builder;
import io.r2dbc.spi.Option;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.boot.r2dbc.EmbeddedDatabaseConnection;
@ -31,6 +29,9 @@ import org.springframework.util.StringUtils;
* Initialize a {@link Builder} based on {@link R2dbcProperties}.
*
* @author Stephane Nicoll
* @author Moritz Halbritter
* @author Andy Wilkinson
* @author Phillip Webb
*/
class ConnectionFactoryOptionsInitializer {
@ -38,45 +39,31 @@ class ConnectionFactoryOptionsInitializer {
* Initialize a {@link Builder ConnectionFactoryOptions.Builder} using the specified
* properties.
* @param properties the properties to use to initialize the builder
* @param connectionDetails the connection details to use to initialize the builder
* @param embeddedDatabaseConnection the embedded connection to use as a fallback
* @return an initialized builder
* @throws ConnectionFactoryBeanCreationException if no suitable connection could be
* determined
*/
ConnectionFactoryOptions.Builder initialize(R2dbcProperties properties,
ConnectionFactoryOptions.Builder initialize(R2dbcProperties properties, R2dbcConnectionDetails connectionDetails,
Supplier<EmbeddedDatabaseConnection> embeddedDatabaseConnection) {
if (StringUtils.hasText(properties.getUrl())) {
return initializeRegularOptions(properties);
if (connectionDetails != null) {
return connectionDetails.getConnectionFactoryOptions().mutate();
}
EmbeddedDatabaseConnection embeddedConnection = embeddedDatabaseConnection.get();
if (embeddedConnection != EmbeddedDatabaseConnection.NONE) {
return initializeEmbeddedOptions(properties, embeddedConnection);
}
throw connectionFactoryBeanCreationException("Failed to determine a suitable R2DBC Connection URL", properties,
throw connectionFactoryBeanCreationException("Failed to determine a suitable R2DBC Connection URL", null,
embeddedConnection);
}
private ConnectionFactoryOptions.Builder initializeRegularOptions(R2dbcProperties properties) {
ConnectionFactoryOptions urlOptions = ConnectionFactoryOptions.parse(properties.getUrl());
Builder optionsBuilder = urlOptions.mutate();
configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.USER, properties::getUsername,
StringUtils::hasText);
configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.PASSWORD, properties::getPassword,
StringUtils::hasText);
configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.DATABASE,
() -> determineDatabaseName(properties), StringUtils::hasText);
if (properties.getProperties() != null) {
properties.getProperties().forEach((key, value) -> optionsBuilder.option(Option.valueOf(key), value));
}
return optionsBuilder;
}
private Builder initializeEmbeddedOptions(R2dbcProperties properties,
EmbeddedDatabaseConnection embeddedDatabaseConnection) {
String url = embeddedDatabaseConnection.getUrl(determineEmbeddedDatabaseName(properties));
if (url == null) {
throw connectionFactoryBeanCreationException("Failed to determine a suitable R2DBC Connection URL",
properties, embeddedDatabaseConnection);
throw connectionFactoryBeanCreationException("Failed to determine a suitable R2DBC Connection URL", url,
embeddedDatabaseConnection);
}
Builder builder = ConnectionFactoryOptions.parse(url).mutate();
String username = determineEmbeddedUsername(properties);
@ -89,6 +76,11 @@ class ConnectionFactoryOptionsInitializer {
return builder;
}
private String determineEmbeddedDatabaseName(R2dbcProperties properties) {
String databaseName = determineDatabaseName(properties);
return (databaseName != null) ? databaseName : "testdb";
}
private String determineDatabaseName(R2dbcProperties properties) {
if (properties.isGenerateUniqueName()) {
return properties.determineUniqueName();
@ -99,30 +91,14 @@ class ConnectionFactoryOptionsInitializer {
return null;
}
private String determineEmbeddedDatabaseName(R2dbcProperties properties) {
String databaseName = determineDatabaseName(properties);
return (databaseName != null) ? databaseName : "testdb";
}
private String determineEmbeddedUsername(R2dbcProperties properties) {
String username = ifHasText(properties.getUsername());
return (username != null) ? username : "sa";
}
private <T extends CharSequence> void configureIf(Builder optionsBuilder, ConnectionFactoryOptions originalOptions,
Option<T> option, Supplier<T> valueSupplier, Predicate<T> setIf) {
if (originalOptions.hasOption(option)) {
return;
}
T value = valueSupplier.get();
if (setIf.test(value)) {
optionsBuilder.option(option, value);
}
}
private ConnectionFactoryBeanCreationException connectionFactoryBeanCreationException(String message,
R2dbcProperties properties, EmbeddedDatabaseConnection embeddedDatabaseConnection) {
return new ConnectionFactoryBeanCreationException(message, properties, embeddedDatabaseConnection);
String r2dbcUrl, EmbeddedDatabaseConnection embeddedDatabaseConnection) {
return new ConnectionFactoryBeanCreationException(message, r2dbcUrl, embeddedDatabaseConnection);
}
private String ifHasText(String candidate) {
@ -131,25 +107,25 @@ class ConnectionFactoryOptionsInitializer {
static class ConnectionFactoryBeanCreationException extends BeanCreationException {
private final R2dbcProperties properties;
private final String url;
private final EmbeddedDatabaseConnection embeddedDatabaseConnection;
ConnectionFactoryBeanCreationException(String message, R2dbcProperties properties,
ConnectionFactoryBeanCreationException(String message, String url,
EmbeddedDatabaseConnection embeddedDatabaseConnection) {
super(message);
this.properties = properties;
this.url = url;
this.embeddedDatabaseConnection = embeddedDatabaseConnection;
}
String getUrl() {
return this.url;
}
EmbeddedDatabaseConnection getEmbeddedDatabaseConnection() {
return this.embeddedDatabaseConnection;
}
R2dbcProperties getProperties() {
return this.properties;
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-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.
@ -16,16 +16,26 @@
package org.springframework.boot.autoconfigure.r2dbc;
import java.util.function.Predicate;
import java.util.function.Supplier;
import io.r2dbc.spi.ConnectionFactory;
import io.r2dbc.spi.ConnectionFactoryOptions;
import io.r2dbc.spi.ConnectionFactoryOptions.Builder;
import io.r2dbc.spi.Option;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnResource;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.util.StringUtils;
/**
* {@link EnableAutoConfiguration Auto-configuration} for R2DBC.
@ -42,4 +52,63 @@ import org.springframework.context.annotation.Import;
ConnectionFactoryConfigurations.GenericConfiguration.class, ConnectionFactoryDependentConfiguration.class })
public class R2dbcAutoConfiguration {
@Bean
@ConditionalOnMissingBean(R2dbcConnectionDetails.class)
@ConditionalOnProperty("spring.r2dbc.url")
PropertiesR2dbcConnectionDetails propertiesR2dbcConnectionDetails(R2dbcProperties properties) {
return new PropertiesR2dbcConnectionDetails(properties);
}
/**
* Adapts {@link R2dbcProperties} to {@link R2dbcConnectionDetails}.
*/
static class PropertiesR2dbcConnectionDetails implements R2dbcConnectionDetails {
private final R2dbcProperties properties;
PropertiesR2dbcConnectionDetails(R2dbcProperties properties) {
this.properties = properties;
}
@Override
public ConnectionFactoryOptions getConnectionFactoryOptions() {
ConnectionFactoryOptions urlOptions = ConnectionFactoryOptions.parse(this.properties.getUrl());
Builder optionsBuilder = urlOptions.mutate();
configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.USER, this.properties::getUsername,
StringUtils::hasText);
configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.PASSWORD, this.properties::getPassword,
StringUtils::hasText);
configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.DATABASE,
() -> determineDatabaseName(this.properties), StringUtils::hasText);
if (this.properties.getProperties() != null) {
this.properties.getProperties()
.forEach((key, value) -> optionsBuilder.option(Option.valueOf(key), value));
}
return optionsBuilder.build();
}
private <T extends CharSequence> void configureIf(Builder optionsBuilder,
ConnectionFactoryOptions originalOptions, Option<T> option, Supplier<T> valueSupplier,
Predicate<T> setIf) {
if (originalOptions.hasOption(option)) {
return;
}
T value = valueSupplier.get();
if (setIf.test(value)) {
optionsBuilder.option(option, value);
}
}
private String determineDatabaseName(R2dbcProperties properties) {
if (properties.isGenerateUniqueName()) {
return properties.determineUniqueName();
}
if (StringUtils.hasLength(properties.getName())) {
return properties.getName();
}
return null;
}
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright 2012-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.boot.autoconfigure.r2dbc;
import io.r2dbc.spi.ConnectionFactoryOptions;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
/**
* Details required to establish a connection to an SQL service using R2DBC.
*
* @author Moritz Halbritter
* @author Andy Wilkinson
* @author Phillip Webb
* @since 3.1.0
*/
public interface R2dbcConnectionDetails extends ConnectionDetails {
/**
* Connection factory options for connecting to the database.
* @return the connection factory options
*/
ConnectionFactoryOptions getConnectionFactoryOptions();
}

View File

@ -27,6 +27,7 @@ import io.r2dbc.h2.H2ConnectionFactory;
import io.r2dbc.pool.ConnectionPool;
import io.r2dbc.pool.PoolMetrics;
import io.r2dbc.spi.ConnectionFactory;
import io.r2dbc.spi.ConnectionFactoryOptions;
import io.r2dbc.spi.ConnectionFactoryProvider;
import io.r2dbc.spi.Option;
import io.r2dbc.spi.Wrapped;
@ -54,6 +55,9 @@ import static org.assertj.core.api.Assertions.assertThat;
*
* @author Mark Paluch
* @author Stephane Nicoll
* @author Moritz Halbritter
* @author Andy Wilkinson
* @author Phillip Webb
*/
class R2dbcAutoConfigurationTests {
@ -68,7 +72,7 @@ class R2dbcAutoConfigurationTests {
assertThat(context.getBean(ConnectionPool.class)).extracting(ConnectionPool::unwrap)
.satisfies((connectionFactory) -> assertThat(connectionFactory)
.asInstanceOf(type(OptionsCapableConnectionFactory.class))
.extracting(Wrapped<ConnectionFactory>::unwrap)
.extracting(Wrapped::unwrap)
.isExactlyInstanceOf(H2ConnectionFactory.class));
});
}
@ -306,6 +310,64 @@ class R2dbcAutoConfigurationTests {
.doesNotHaveBean(DatabaseClient.class));
}
@Test
void shouldUseCustomConnectionDetailsIfAvailable() {
this.contextRunner.withPropertyValues("spring.r2dbc.pool.enabled=false")
.withUserConfiguration(ConnectionDetailsConfiguration.class)
.run((context) -> {
assertThat(context).hasSingleBean(ConnectionFactory.class);
OptionsCapableConnectionFactory connectionFactory = context
.getBean(OptionsCapableConnectionFactory.class);
ConnectionFactoryOptions options = connectionFactory.getOptions();
assertThat(options.getValue(ConnectionFactoryOptions.DRIVER)).isEqualTo("postgresql");
assertThat(options.getValue(ConnectionFactoryOptions.HOST)).isEqualTo("postgres.example.com");
assertThat(options.getValue(ConnectionFactoryOptions.PORT)).isEqualTo(12345);
assertThat(options.getValue(ConnectionFactoryOptions.DATABASE)).isEqualTo("database-1");
assertThat(options.getValue(ConnectionFactoryOptions.USER)).isEqualTo("user-1");
assertThat(options.getValue(ConnectionFactoryOptions.PASSWORD)).isEqualTo("password-1");
});
}
@Test
void configureWithUsernamePasswordAndUrlWithoutUserInfoUsesUsernameAndPassword() {
this.contextRunner
.withPropertyValues("spring.r2dbc.pool.enabled=false",
"spring.r2dbc.url:r2dbc:postgresql://postgres.example.com:4321/db", "spring.r2dbc.username=alice",
"spring.r2dbc.password=secret")
.run((context) -> {
assertThat(context).hasSingleBean(ConnectionFactory.class);
OptionsCapableConnectionFactory connectionFactory = context
.getBean(OptionsCapableConnectionFactory.class);
ConnectionFactoryOptions options = connectionFactory.getOptions();
assertThat(options.getValue(ConnectionFactoryOptions.DRIVER)).isEqualTo("postgresql");
assertThat(options.getValue(ConnectionFactoryOptions.HOST)).isEqualTo("postgres.example.com");
assertThat(options.getValue(ConnectionFactoryOptions.PORT)).isEqualTo(4321);
assertThat(options.getValue(ConnectionFactoryOptions.DATABASE)).isEqualTo("db");
assertThat(options.getValue(ConnectionFactoryOptions.USER)).isEqualTo("alice");
assertThat(options.getValue(ConnectionFactoryOptions.PASSWORD)).isEqualTo("secret");
});
}
@Test
void configureWithUsernamePasswordAndUrlWithUserInfoUsesUserInfo() {
this.contextRunner
.withPropertyValues("spring.r2dbc.pool.enabled=false",
"spring.r2dbc.url:r2dbc:postgresql://bob:password@postgres.example.com:9876/db",
"spring.r2dbc.username=alice", "spring.r2dbc.password=secret")
.run((context) -> {
assertThat(context).hasSingleBean(ConnectionFactory.class);
OptionsCapableConnectionFactory connectionFactory = context
.getBean(OptionsCapableConnectionFactory.class);
ConnectionFactoryOptions options = connectionFactory.getOptions();
assertThat(options.getValue(ConnectionFactoryOptions.DRIVER)).isEqualTo("postgresql");
assertThat(options.getValue(ConnectionFactoryOptions.HOST)).isEqualTo("postgres.example.com");
assertThat(options.getValue(ConnectionFactoryOptions.PORT)).isEqualTo(9876);
assertThat(options.getValue(ConnectionFactoryOptions.DATABASE)).isEqualTo("db");
assertThat(options.getValue(ConnectionFactoryOptions.USER)).isEqualTo("bob");
assertThat(options.getValue(ConnectionFactoryOptions.PASSWORD)).isEqualTo("password");
});
}
private <T> InstanceOfAssertFactory<T, ObjectAssert<T>> type(Class<T> type) {
return InstanceOfAssertFactories.type(type);
}
@ -342,4 +404,22 @@ class R2dbcAutoConfigurationTests {
}
@Configuration(proxyBeanMethods = false)
static class ConnectionDetailsConfiguration {
@Bean
R2dbcConnectionDetails r2dbcConnectionDetails() {
return new R2dbcConnectionDetails() {
@Override
public ConnectionFactoryOptions getConnectionFactoryOptions() {
return ConnectionFactoryOptions
.parse("r2dbc:postgresql://user-1:password-1@postgres.example.com:12345/database-1");
}
};
}
}
}