#104 - Provide extension mechanism for R2dbcDialect resolution.

We now use Spring's spring.factories mechanism to register and lookup R2dbcDialectProvider implementations.
This allows a pluggable model for third party implementations to ship R2dbcDialect support that can be auto-discovered.

Original pull request: #127.
This commit is contained in:
Mark Paluch
2019-05-22 16:55:32 +02:00
parent 85dcf6b71d
commit 043e8a6855
11 changed files with 269 additions and 180 deletions

View File

@@ -4,6 +4,7 @@
:revdate: {localdate}
ifdef::backend-epub3[:front-cover-image: image:epub-cover.png[Front Cover,1050,1600]]
:spring-data-commons-docs: ../../../../spring-data-commons/src/main/asciidoc
:spring-data-r2dbc-javadoc: https://docs.spring.io/spring-data/r2dbc/docs/{version}/api
:reactiveStreamsJavadoc: https://www.reactive-streams.org/reactive-streams-{reactiveStreamsVersion}-javadoc
(C) 2018-2019 The original authors.

View File

@@ -193,7 +193,7 @@ Even in this simple example, there are few things to notice:
There is a https://github.com/spring-projects/spring-data-examples[GitHub repository with several examples] that you can download and play around with to get a feel for how the library works.
[[r2dbc.drivers]]
[[r2dbc.connecting]]
== Connecting to a Relational Database with Spring
One of the first tasks when using relational databases and Spring is to create a `io.r2dbc.spi.ConnectionFactory` object using the IoC container. The following example explains Java-based configuration.
@@ -235,6 +235,11 @@ As of writing the following drivers are available:
* https://github.com/r2dbc/r2dbc-mssql[Microsoft SQL Server] (`io.r2dbc:r2dbc-mssql`)
* https://github.com/jasync-sql/jasync-sql[jasync-sql MySQL] (`com.github.jasync-sql:jasync-r2dbc-mysql`)
Spring Data R2DBC reacts to database specifics by inspecting `ConnectionFactoryMetadata` exposed by the `ConnectionFactory` and selects the appropriate database dialect accordingly.
You can configure an own https://docs.spring.io/spring-data/r2dbc/docs/{version}/api/org/springframework/data/r2dbc/dialect/R2dbcDialect.html[`R2dbcDialect`] if the used driver is not yet known to Spring Data R2DBC.
Spring Data R2DBC reacts to database specifics by inspecting the `ConnectionFactory` and selects the appropriate database dialect accordingly.
You can configure an own {spring-data-r2dbc-javadoc}/api/org/springframework/data/r2dbc/dialect/R2dbcDialect.html[`R2dbcDialect`] if the used driver is not yet known to Spring Data R2DBC.
TIP: Dialects are resolved by {spring-data-r2dbc-javadoc}/org/springframework/data/r2dbc/dialect/DialectResolver.html[`DialectResolver`] from a `ConnectionFactory`, typically by inspecting `ConnectionFactoryMetadata`. +
+
You can let Spring auto-discover your `R2dbcDialect` by registering a class that implements `org.springframework.data.r2dbc.dialect.DialectResolver$R2dbcDialectProvider` through `META-INF/spring.factories`. +
`DialectResolver` discovers dialect provider implementations from the class path using Spring's `SpringFactoriesLoader`.

View File

@@ -33,7 +33,7 @@ import org.springframework.data.r2dbc.convert.R2dbcCustomConversions;
import org.springframework.data.r2dbc.core.DatabaseClient;
import org.springframework.data.r2dbc.core.DefaultReactiveDataAccessStrategy;
import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy;
import org.springframework.data.r2dbc.dialect.Database;
import org.springframework.data.r2dbc.dialect.DialectResolver;
import org.springframework.data.r2dbc.dialect.R2dbcDialect;
import org.springframework.data.r2dbc.support.R2dbcExceptionSubclassTranslator;
import org.springframework.data.r2dbc.support.R2dbcExceptionTranslator;
@@ -87,12 +87,7 @@ public abstract class AbstractR2dbcConfiguration implements ApplicationContextAw
* @throws UnsupportedOperationException if the {@link R2dbcDialect} cannot be determined.
*/
public R2dbcDialect getDialect(ConnectionFactory connectionFactory) {
return Database.findDatabase(connectionFactory)
.orElseThrow(() -> new UnsupportedOperationException(
String.format("Cannot determine a dialect for %s using %s. Please provide a Dialect.",
connectionFactory.getMetadata().getName(), connectionFactory)))
.defaultDialect();
return DialectResolver.getDialect(connectionFactory);
}
/**

View File

@@ -21,7 +21,7 @@ import io.r2dbc.spi.ConnectionFactory;
import java.util.function.Consumer;
import org.springframework.data.r2dbc.core.DatabaseClient.Builder;
import org.springframework.data.r2dbc.dialect.Database;
import org.springframework.data.r2dbc.dialect.DialectResolver;
import org.springframework.data.r2dbc.dialect.R2dbcDialect;
import org.springframework.data.r2dbc.support.R2dbcExceptionSubclassTranslator;
import org.springframework.data.r2dbc.support.R2dbcExceptionTranslator;
@@ -121,10 +121,7 @@ class DefaultDatabaseClientBuilder implements DatabaseClient.Builder {
if (accessStrategy == null) {
R2dbcDialect dialect = Database.findDatabase(this.connectionFactory)
.orElseThrow(() -> new UnsupportedOperationException(
"Cannot determine a Dialect. Configure the dialect by providing DefaultReactiveDataAccessStrategy(Dialect)"))
.defaultDialect();
R2dbcDialect dialect = DialectResolver.getDialect(this.connectionFactory);
accessStrategy = new DefaultReactiveDataAccessStrategy(dialect);
}

View File

@@ -1,105 +0,0 @@
package org.springframework.data.r2dbc.dialect;
import io.r2dbc.spi.ConnectionFactory;
import io.r2dbc.spi.ConnectionFactoryMetadata;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import org.springframework.util.Assert;
/**
* Enumeration of known Databases for offline {@link R2dbcDialect} resolution. R2DBC
* {@link io.r2dbc.spi.ConnectionFactory} provides {@link io.r2dbc.spi.ConnectionFactoryMetadata metadata} that allows
* resolving an appropriate {@link R2dbcDialect} if none was configured explicitly.
*
* @author Mark Paluch
* @author Jens Schauder
*/
public enum Database {
POSTGRES {
@Override
public String driverName() {
return "PostgreSQL";
}
@Override
public R2dbcDialect defaultDialect() {
return PostgresDialect.INSTANCE;
}
},
SQL_SERVER {
@Override
public String driverName() {
return "Microsoft SQL Server";
}
@Override
public R2dbcDialect defaultDialect() {
return SqlServerDialect.INSTANCE;
}
},
H2 {
@Override
public String driverName() {
return "H2";
}
@Override
public R2dbcDialect defaultDialect() {
return H2Dialect.INSTANCE;
}
},
MYSQL {
@Override
public String driverName() {
return "MySQL";
}
@Override
public R2dbcDialect defaultDialect() {
return MySqlDialect.INSTANCE;
}
};
/**
* Find a {@link Database} type using {@link ConnectionFactory} and its metadata.
*
* @param connectionFactory must not be {@literal null}.
* @return the resolved {@link Database} or {@link Optional#empty()} if the database type cannot be determined from
* {@link ConnectionFactory}.
*/
public static Optional<Database> findDatabase(ConnectionFactory connectionFactory) {
Assert.notNull(connectionFactory, "ConnectionFactory must not be null!");
ConnectionFactoryMetadata metadata = connectionFactory.getMetadata();
return Arrays.stream(values()).filter(it -> matches(metadata, it.driverName())).findFirst();
}
private static boolean matches(ConnectionFactoryMetadata metadata, String databaseType) {
return metadata.getName().toLowerCase(Locale.ENGLISH).contains(databaseType.toLowerCase(Locale.ENGLISH));
}
/**
* Returns the driver name.
*
* @return the driver name.
* @see ConnectionFactoryMetadata#getName()
*/
public abstract String driverName();
/**
* Returns the latest {@link R2dbcDialect} for the underlying database.
*
* @return the latest {@link R2dbcDialect} for the underlying database.
*/
public abstract R2dbcDialect defaultDialect();
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2019 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.data.r2dbc.dialect;
import io.r2dbc.spi.ConnectionFactory;
import io.r2dbc.spi.ConnectionFactoryMetadata;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.dao.NonTransientDataAccessException;
import org.springframework.util.LinkedCaseInsensitiveMap;
/**
* Resolves a {@link R2dbcDialect} from a {@link ConnectionFactory} using {@link R2dbcDialectProvider}. Dialect
* resolution uses Spring's {@link SpringFactoriesLoader spring.factories} to determine available extensions.
*
* @author Mark Paluch
* @see R2dbcDialect
* @see SpringFactoriesLoader
*/
public class DialectResolver {
private static final List<R2dbcDialectProvider> DETECTORS = SpringFactoriesLoader
.loadFactories(R2dbcDialectProvider.class, DialectResolver.class.getClassLoader());
// utility constructor.
private DialectResolver() {}
/**
* Retrieve a {@link R2dbcDialect} by inspecting {@link ConnectionFactory} and its metadata.
*
* @param connectionFactory must not be {@literal null}.
* @return the resolved {@link R2dbcDialect} {@link NoDialectException} if the database type cannot be determined from
* {@link ConnectionFactory}.
* @throws NoDialectException if no {@link R2dbcDialect} can be found.
*/
public static R2dbcDialect getDialect(ConnectionFactory connectionFactory) {
return DETECTORS.stream() //
.map(it -> it.getDialect(connectionFactory)) //
.filter(Optional::isPresent) //
.findFirst() //
.flatMap(it -> it) //
.orElseThrow(() -> {
return new NoDialectException(
String.format("Cannot determine a dialect for %s using %s. Please provide a Dialect.",
connectionFactory.getMetadata().getName(), connectionFactory));
});
}
/**
* SPI to extend Spring's default R2DBC Dialect discovery mechanism. Implementations of this interface are discovered
* through Spring's {@link SpringFactoriesLoader} mechanism.
*
* @author Mark Paluch
* @see org.springframework.core.io.support.SpringFactoriesLoader
*/
public interface R2dbcDialectProvider {
/**
* Returns a {@link R2dbcDialect} for a {@link ConnectionFactory}.
*
* @param connectionFactory the connection factory to be used with the {@link R2dbcDialect}.
* @return {@link Optional} containing the {@link R2dbcDialect} if the {@link R2dbcDialectProvider} can provide a
* dialect object, otherwise {@link Optional#empty()}.
*/
Optional<R2dbcDialect> getDialect(ConnectionFactory connectionFactory);
}
/**
* Exception thrown when {@link DialectResolver} cannot resolve a {@link R2dbcDialect}.
*/
public static class NoDialectException extends NonTransientDataAccessException {
/**
* Constructor for NoDialectFoundException.
*
* @param msg the detail message
*/
public NoDialectException(String msg) {
super(msg);
}
}
/**
* Built-in dialects. Used typically as last {@link R2dbcDialectProvider} when other providers register with a higher
* precedence.
*
* @see org.springframework.core.Ordered
* @see org.springframework.core.annotation.AnnotationAwareOrderComparator
*/
static class BuiltInDialectProvider implements R2dbcDialectProvider {
private static final Map<String, R2dbcDialect> BUILTIN = new LinkedCaseInsensitiveMap<>(Locale.ENGLISH);
static {
BUILTIN.put("H2", H2Dialect.INSTANCE);
BUILTIN.put("Microsoft SQL Server", SqlServerDialect.INSTANCE);
BUILTIN.put("MySQL", MySqlDialect.INSTANCE);
BUILTIN.put("PostgreSQL", PostgresDialect.INSTANCE);
}
@Override
public Optional<R2dbcDialect> getDialect(ConnectionFactory connectionFactory) {
ConnectionFactoryMetadata metadata = connectionFactory.getMetadata();
R2dbcDialect r2dbcDialect = BUILTIN.get(metadata.getName());
if (r2dbcDialect != null) {
return Optional.of(r2dbcDialect);
}
for (String key : BUILTIN.keySet()) {
if (metadata.getName().contains(key)) {
return Optional.of(BUILTIN.get(key));
}
}
return Optional.empty();
}
}
}

View File

@@ -0,0 +1 @@
org.springframework.data.r2dbc.dialect.DialectResolver$R2dbcDialectProvider=org.springframework.data.r2dbc.dialect.DialectResolver.BuiltInDialectProvider

View File

@@ -1,56 +0,0 @@
package org.springframework.data.r2dbc.dialect;
import static org.assertj.core.api.Assertions.*;
import io.r2dbc.h2.H2ConnectionConfiguration;
import io.r2dbc.h2.H2ConnectionFactory;
import io.r2dbc.mssql.MssqlConnectionConfiguration;
import io.r2dbc.mssql.MssqlConnectionFactory;
import io.r2dbc.postgresql.PostgresqlConnectionConfiguration;
import io.r2dbc.postgresql.PostgresqlConnectionFactory;
import io.r2dbc.spi.Connection;
import io.r2dbc.spi.ConnectionFactory;
import io.r2dbc.spi.ConnectionFactoryMetadata;
import org.junit.Test;
import org.reactivestreams.Publisher;
/**
* Unit tests for {@link Database}.
*
* @author Mark Paluch
*/
public class DatabaseUnitTests {
@Test // gh-20
public void shouldResolveDatabaseType() {
PostgresqlConnectionFactory postgres = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder()
.host("localhost").database("foo").username("bar").password("password").build());
MssqlConnectionFactory mssql = new MssqlConnectionFactory(MssqlConnectionConfiguration.builder().host("localhost")
.database("foo").username("bar").password("password").build());
H2ConnectionFactory h2 = new H2ConnectionFactory(H2ConnectionConfiguration.builder().inMemory("mem").build());
assertThat(Database.findDatabase(postgres)).contains(Database.POSTGRES);
assertThat(Database.findDatabase(mssql)).contains(Database.SQL_SERVER);
assertThat(Database.findDatabase(h2)).contains(Database.H2);
}
@Test // gh-20
public void shouldNotResolveUnknownDatabase() {
assertThat(Database.findDatabase(new UnknownConnectionFactory())).isEmpty();
}
static class UnknownConnectionFactory implements ConnectionFactory {
@Override
public Publisher<? extends Connection> create() {
throw new UnsupportedOperationException();
}
@Override
public ConnectionFactoryMetadata getMetadata() {
return () -> "foo";
}
}
}

View File

@@ -0,0 +1,110 @@
package org.springframework.data.r2dbc.dialect;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.r2dbc.h2.H2ConnectionConfiguration;
import io.r2dbc.h2.H2ConnectionFactory;
import io.r2dbc.mssql.MssqlConnectionConfiguration;
import io.r2dbc.mssql.MssqlConnectionFactory;
import io.r2dbc.postgresql.PostgresqlConnectionConfiguration;
import io.r2dbc.postgresql.PostgresqlConnectionFactory;
import io.r2dbc.spi.Connection;
import io.r2dbc.spi.ConnectionFactory;
import io.r2dbc.spi.ConnectionFactoryMetadata;
import lombok.RequiredArgsConstructor;
import java.util.Optional;
import org.junit.Test;
import org.reactivestreams.Publisher;
import org.springframework.data.relational.core.dialect.LimitClause;
import org.springframework.data.relational.core.sql.render.SelectRenderContext;
import com.github.jasync.r2dbc.mysql.JasyncConnectionFactory;
import com.github.jasync.sql.db.mysql.pool.MySQLConnectionFactory;
/**
* Unit tests for {@link DialectResolver}.
*
* @author Mark Paluch
*/
public class DialectResolverUnitTests {
@Test // gh-20, gh-104
public void shouldResolveDatabaseType() {
PostgresqlConnectionFactory postgres = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder()
.host("localhost").database("foo").username("bar").password("password").build());
MssqlConnectionFactory mssql = new MssqlConnectionFactory(MssqlConnectionConfiguration.builder().host("localhost")
.database("foo").username("bar").password("password").build());
H2ConnectionFactory h2 = new H2ConnectionFactory(H2ConnectionConfiguration.builder().inMemory("mem").build());
JasyncConnectionFactory mysql = new JasyncConnectionFactory(mock(MySQLConnectionFactory.class));
assertThat(DialectResolver.getDialect(postgres)).isEqualTo(PostgresDialect.INSTANCE);
assertThat(DialectResolver.getDialect(mssql)).isEqualTo(SqlServerDialect.INSTANCE);
assertThat(DialectResolver.getDialect(h2)).isEqualTo(H2Dialect.INSTANCE);
assertThat(DialectResolver.getDialect(mysql)).isEqualTo(MySqlDialect.INSTANCE);
}
@Test // gh-20, gh-104
public void shouldNotResolveUnknownDatabase() {
assertThatThrownBy(() -> DialectResolver.getDialect(new ExternalConnectionFactory("unknown")))
.isInstanceOf(DialectResolver.NoDialectException.class);
}
@Test // gh-104
public void shouldResolveExternalDialect() {
assertThat(DialectResolver.getDialect(new ExternalConnectionFactory("external")))
.isEqualTo(ExternalDialect.INSTANCE);
}
@RequiredArgsConstructor
static class ExternalConnectionFactory implements ConnectionFactory {
private final String name;
@Override
public Publisher<? extends Connection> create() {
throw new UnsupportedOperationException();
}
@Override
public ConnectionFactoryMetadata getMetadata() {
return () -> this.name;
}
}
static class ExternalDialectProvider implements DialectResolver.R2dbcDialectProvider {
@Override
public Optional<R2dbcDialect> getDialect(ConnectionFactory connectionFactory) {
if (connectionFactory.getMetadata().getName().equals("external")) {
return Optional.of(ExternalDialect.INSTANCE);
}
return Optional.empty();
}
}
enum ExternalDialect implements R2dbcDialect {
INSTANCE;
@Override
public BindMarkersFactory getBindMarkersFactory() {
return null;
}
@Override
public LimitClause limit() {
return null;
}
@Override
public SelectRenderContext getSelectContext() {
return null;
}
}
}

View File

@@ -42,7 +42,8 @@ import org.springframework.data.annotation.PersistenceConstructor;
import org.springframework.data.r2dbc.convert.MappingR2dbcConverter;
import org.springframework.data.r2dbc.core.DefaultReactiveDataAccessStrategy;
import org.springframework.data.r2dbc.core.TransactionalDatabaseClient;
import org.springframework.data.r2dbc.dialect.Database;
import org.springframework.data.r2dbc.dialect.DialectResolver;
import org.springframework.data.r2dbc.dialect.R2dbcDialect;
import org.springframework.data.r2dbc.repository.support.R2dbcRepositoryFactory;
import org.springframework.data.r2dbc.testing.R2dbcIntegrationTestSupport;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
@@ -172,9 +173,9 @@ public abstract class AbstractR2dbcRepositoryIntegrationTests extends R2dbcInteg
@Test
public void shouldInsertItemsTransactional() {
Database database = Database.findDatabase(createConnectionFactory()).get();
DefaultReactiveDataAccessStrategy dataAccessStrategy = new DefaultReactiveDataAccessStrategy(
database.defaultDialect(), new MappingR2dbcConverter(mappingContext));
R2dbcDialect dialect = DialectResolver.getDialect(createConnectionFactory());
DefaultReactiveDataAccessStrategy dataAccessStrategy = new DefaultReactiveDataAccessStrategy(dialect,
new MappingR2dbcConverter(mappingContext));
TransactionalDatabaseClient client = TransactionalDatabaseClient.builder()
.connectionFactory(createConnectionFactory()).dataAccessStrategy(dataAccessStrategy).build();

View File

@@ -0,0 +1 @@
org.springframework.data.r2dbc.dialect.DialectResolver$R2dbcDialectProvider=org.springframework.data.r2dbc.dialect.DialectResolverUnitTests.ExternalDialectProvider