diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 336662c9..34f5487b 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -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. diff --git a/src/main/asciidoc/reference/r2dbc-core.adoc b/src/main/asciidoc/reference/r2dbc-core.adoc index a4c74be9..5a704aa1 100644 --- a/src/main/asciidoc/reference/r2dbc-core.adoc +++ b/src/main/asciidoc/reference/r2dbc-core.adoc @@ -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`. diff --git a/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java b/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java index 2f509179..6b1d1bbb 100644 --- a/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java +++ b/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java @@ -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); } /** diff --git a/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientBuilder.java b/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientBuilder.java index 8853f5be..5a775048 100644 --- a/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientBuilder.java +++ b/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientBuilder.java @@ -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); } diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/Database.java b/src/main/java/org/springframework/data/r2dbc/dialect/Database.java deleted file mode 100644 index 64f7f950..00000000 --- a/src/main/java/org/springframework/data/r2dbc/dialect/Database.java +++ /dev/null @@ -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 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(); - -} diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/DialectResolver.java b/src/main/java/org/springframework/data/r2dbc/dialect/DialectResolver.java new file mode 100644 index 00000000..a48dc22f --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/dialect/DialectResolver.java @@ -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 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 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 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 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(); + } + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..b7cf4812 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.data.r2dbc.dialect.DialectResolver$R2dbcDialectProvider=org.springframework.data.r2dbc.dialect.DialectResolver.BuiltInDialectProvider diff --git a/src/test/java/org/springframework/data/r2dbc/dialect/DatabaseUnitTests.java b/src/test/java/org/springframework/data/r2dbc/dialect/DatabaseUnitTests.java deleted file mode 100644 index d28c171f..00000000 --- a/src/test/java/org/springframework/data/r2dbc/dialect/DatabaseUnitTests.java +++ /dev/null @@ -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 create() { - throw new UnsupportedOperationException(); - } - - @Override - public ConnectionFactoryMetadata getMetadata() { - return () -> "foo"; - } - } -} diff --git a/src/test/java/org/springframework/data/r2dbc/dialect/DialectResolverUnitTests.java b/src/test/java/org/springframework/data/r2dbc/dialect/DialectResolverUnitTests.java new file mode 100644 index 00000000..29fa29a3 --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/dialect/DialectResolverUnitTests.java @@ -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 create() { + throw new UnsupportedOperationException(); + } + + @Override + public ConnectionFactoryMetadata getMetadata() { + return () -> this.name; + } + } + + static class ExternalDialectProvider implements DialectResolver.R2dbcDialectProvider { + + @Override + public Optional 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; + } + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java index 1f8cd34b..590167db 100644 --- a/src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java +++ b/src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java @@ -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(); diff --git a/src/test/resources/META-INF/spring.factories b/src/test/resources/META-INF/spring.factories new file mode 100644 index 00000000..fd83c358 --- /dev/null +++ b/src/test/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.data.r2dbc.dialect.DialectResolver$R2dbcDialectProvider=org.springframework.data.r2dbc.dialect.DialectResolverUnitTests.ExternalDialectProvider