diff --git a/src/main/asciidoc/reference/r2dbc-fluent.adoc b/src/main/asciidoc/reference/r2dbc-fluent.adoc index a3d8b78..d57160b 100644 --- a/src/main/asciidoc/reference/r2dbc-fluent.adoc +++ b/src/main/asciidoc/reference/r2dbc-fluent.adoc @@ -40,14 +40,18 @@ Mono first = databaseClient.select() <1> Selecting from a table by name returns row results as `Map` with case-insensitive column name matching. <2> The issued query declares a `WHERE` condition on `firstname` and `lastname` columns to filter results. <3> Results can be ordered by individual column names, resulting in an `ORDER BY` clause. -<4> Selecting the one result fetches only a single row. This way of consuming rows expects the query to return exactly a single result. +<4> Selecting the one result fetches only a single row. +This way of consuming rows expects the query to return exactly a single result. `Mono` emits a `IncorrectResultSizeDataAccessException` if the query yields more than a single result. ==== +TIP: You can directly apply <> to result documents by providing the target type via `as(Class)`. + You can consume Query results in three ways: * Through object mapping (for example, `as(Class)`) by using Spring Data's mapping-metadata. -* As `Map` where column names are mapped to their value. Column names are looked up in a case-insensitive way. +* As `Map` where column names are mapped to their value. +Column names are looked up in a case-insensitive way. * By supplying a mapping `BiFunction` for direct access to R2DBC `Row` and `RowMetadata`. You can switch between retrieving a single entity and retrieving multiple entities through the following terminating methods: 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 9c3bcad..618a11a 100644 --- a/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java +++ b/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java @@ -30,6 +30,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.CustomConversions; import org.springframework.data.convert.CustomConversions.StoreConversions; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.r2dbc.convert.MappingR2dbcConverter; import org.springframework.data.r2dbc.convert.R2dbcCustomConversions; import org.springframework.data.r2dbc.core.DatabaseClient; @@ -106,10 +107,17 @@ public abstract class AbstractR2dbcConfiguration implements ApplicationContextAw Assert.notNull(dataAccessStrategy, "DataAccessStrategy must not be null!"); Assert.notNull(exceptionTranslator, "ExceptionTranslator must not be null!"); + SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); + if (context != null) { + projectionFactory.setBeanFactory(context); + projectionFactory.setBeanClassLoader(context.getClassLoader()); + } + return DatabaseClient.builder() // .connectionFactory(lookupConnectionFactory()) // .dataAccessStrategy(dataAccessStrategy) // .exceptionTranslator(exceptionTranslator) // + .projectionFactory(projectionFactory) // .build(); } diff --git a/src/main/java/org/springframework/data/r2dbc/core/DatabaseClient.java b/src/main/java/org/springframework/data/r2dbc/core/DatabaseClient.java index 1fd1276..e785ba5 100644 --- a/src/main/java/org/springframework/data/r2dbc/core/DatabaseClient.java +++ b/src/main/java/org/springframework/data/r2dbc/core/DatabaseClient.java @@ -30,6 +30,7 @@ import org.reactivestreams.Publisher; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.r2dbc.mapping.SettableValue; import org.springframework.data.r2dbc.query.Criteria; import org.springframework.data.r2dbc.query.Update; @@ -157,6 +158,15 @@ public interface DatabaseClient { */ Builder namedParameters(boolean enabled); + /** + * Configures the {@link org.springframework.data.projection.ProjectionFactory projection factory}. + * + * @param factory must not be {@literal null}. + * @return {@code this} {@link Builder}. + * @since 1.1 + */ + Builder projectionFactory(ProjectionFactory factory); + /** * Configures a {@link Consumer} to configure this builder. * diff --git a/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClient.java b/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClient.java index 8f92968..70bb670 100644 --- a/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClient.java +++ b/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClient.java @@ -49,6 +49,7 @@ import org.springframework.dao.DataAccessException; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.r2dbc.UncategorizedR2dbcException; import org.springframework.data.r2dbc.connectionfactory.ConnectionFactoryUtils; import org.springframework.data.r2dbc.connectionfactory.ConnectionProxy; @@ -82,13 +83,17 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { private final DefaultDatabaseClientBuilder builder; + private final ProjectionFactory projectionFactory; + DefaultDatabaseClient(ConnectionFactory connector, R2dbcExceptionTranslator exceptionTranslator, - ReactiveDataAccessStrategy dataAccessStrategy, boolean namedParameters, DefaultDatabaseClientBuilder builder) { + ReactiveDataAccessStrategy dataAccessStrategy, boolean namedParameters, ProjectionFactory projectionFactory, + DefaultDatabaseClientBuilder builder) { this.connector = connector; this.exceptionTranslator = exceptionTranslator; this.dataAccessStrategy = dataAccessStrategy; this.namedParameters = namedParameters; + this.projectionFactory = projectionFactory; this.builder = builder; } @@ -544,7 +549,7 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { @SuppressWarnings("unchecked") protected class DefaultTypedExecuteSpec extends ExecuteSpecSupport implements TypedExecuteSpec { - private final Class typeToRead; + private final @Nullable Class typeToRead; private final BiFunction mappingFunction; DefaultTypedExecuteSpec(Map byIndex, Map byName, @@ -553,7 +558,13 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { super(byIndex, byName, sqlSupplier); this.typeToRead = typeToRead; - this.mappingFunction = dataAccessStrategy.getRowMapper(typeToRead); + + if (typeToRead.isInterface()) { + this.mappingFunction = ColumnMapRowMapper.INSTANCE + .andThen(map -> projectionFactory.createProjection(typeToRead, map)); + } else { + this.mappingFunction = dataAccessStrategy.getRowMapper(typeToRead); + } } DefaultTypedExecuteSpec(Map byIndex, Map byName, @@ -638,6 +649,9 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { @Override public TypedSelectSpec from(Class table) { + + assertRegularClass(table); + return new DefaultTypedSelectSpec<>(table); } } @@ -735,8 +749,16 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { Assert.notNull(resultType, "Result type must not be null!"); + BiFunction rowMapper; + + if (resultType.isInterface()) { + rowMapper = ColumnMapRowMapper.INSTANCE.andThen(map -> projectionFactory.createProjection(resultType, map)); + } else { + rowMapper = dataAccessStrategy.getRowMapper(resultType); + } + return new DefaultTypedSelectSpec<>(this.table, this.projectedFields, this.criteria, this.sort, this.page, - resultType, dataAccessStrategy.getRowMapper(resultType)); + resultType, rowMapper); } @Override @@ -808,10 +830,10 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { @SuppressWarnings("unchecked") private class DefaultTypedSelectSpec extends DefaultSelectSpecSupport implements TypedSelectSpec { - private final @Nullable Class typeToRead; + private final Class typeToRead; private final BiFunction mappingFunction; - DefaultTypedSelectSpec(@Nullable Class typeToRead) { + DefaultTypedSelectSpec(Class typeToRead) { super(dataAccessStrategy.getTableName(typeToRead)); @@ -833,7 +855,16 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { Assert.notNull(resultType, "Result type must not be null!"); - return exchange(dataAccessStrategy.getRowMapper(resultType)); + BiFunction rowMapper; + + if (resultType.isInterface()) { + rowMapper = dataAccessStrategy.getRowMapper(typeToRead) + .andThen(r -> projectionFactory.createProjection(resultType, r)); + } else { + rowMapper = dataAccessStrategy.getRowMapper(resultType); + } + + return exchange(rowMapper); } @Override @@ -920,6 +951,9 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { @Override public TypedInsertSpec into(Class table) { + + assertRegularClass(table); + return new DefaultTypedInsertSpec<>(table, ColumnMapRowMapper.INSTANCE); } } @@ -1136,6 +1170,9 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { @Override public TypedUpdateSpec table(Class table) { + + assertRegularClass(table); + return new DefaultTypedUpdateSpec<>(table, null, null); } } @@ -1297,6 +1334,9 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { @Override public DefaultDeleteSpec from(Class table) { + + assertRegularClass(table); + return new DefaultDeleteSpec<>(table, null, null); } } @@ -1477,6 +1517,13 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { return sql; } + private static void assertRegularClass(Class table) { + + Assert.notNull(table, "Entity type must not be null"); + Assert.isTrue(!table.isInterface() && !table.isEnum(), + () -> String.format("Entity type %s must be a class", table.getName())); + } + /** * Invocation handler that suppresses close calls on R2DBC Connections. Also prepares returned Statement * (Prepared/CallbackStatement) objects. 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 81292e8..c3a186d 100644 --- a/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientBuilder.java +++ b/src/main/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientBuilder.java @@ -20,6 +20,7 @@ import io.r2dbc.spi.ConnectionFactory; import java.util.function.Consumer; +import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.r2dbc.core.DatabaseClient.Builder; import org.springframework.data.r2dbc.dialect.DialectResolver; import org.springframework.data.r2dbc.dialect.R2dbcDialect; @@ -43,6 +44,8 @@ class DefaultDatabaseClientBuilder implements DatabaseClient.Builder { private boolean namedParameters = true; + private ProjectionFactory projectionFactory; + DefaultDatabaseClientBuilder() {} DefaultDatabaseClientBuilder(DefaultDatabaseClientBuilder other) { @@ -53,6 +56,7 @@ class DefaultDatabaseClientBuilder implements DatabaseClient.Builder { this.exceptionTranslator = other.exceptionTranslator; this.accessStrategy = other.accessStrategy; this.namedParameters = other.namedParameters; + this.projectionFactory = other.projectionFactory; } /* @@ -105,6 +109,19 @@ class DefaultDatabaseClientBuilder implements DatabaseClient.Builder { return this; } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.DatabaseClient.Builder#projectionFactory(ProjectionFactory) + */ + @Override + public Builder projectionFactory(ProjectionFactory factory) { + + Assert.notNull(factory, "ProjectionFactory must not be null!"); + + this.projectionFactory = factory; + return this; + } + /* * (non-Javadoc) * @see org.springframework.data.r2dbc.function.DatabaseClient.Builder#build() @@ -126,13 +143,8 @@ class DefaultDatabaseClientBuilder implements DatabaseClient.Builder { accessStrategy = new DefaultReactiveDataAccessStrategy(dialect); } - return doBuild(this.connectionFactory, exceptionTranslator, accessStrategy, namedParameters, - new DefaultDatabaseClientBuilder(this)); - } - - protected DatabaseClient doBuild(ConnectionFactory connector, R2dbcExceptionTranslator exceptionTranslator, - ReactiveDataAccessStrategy accessStrategy, boolean namedParameters, DefaultDatabaseClientBuilder builder) { - return new DefaultDatabaseClient(connector, exceptionTranslator, accessStrategy, namedParameters, builder); + return new DefaultDatabaseClient(this.connectionFactory, exceptionTranslator, accessStrategy, namedParameters, + projectionFactory, new DefaultDatabaseClientBuilder(this)); } /* diff --git a/src/test/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientUnitTests.java b/src/test/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientUnitTests.java index 4b8ba83..21c39fc 100644 --- a/src/test/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientUnitTests.java +++ b/src/test/java/org/springframework/data/r2dbc/core/DefaultDatabaseClientUnitTests.java @@ -23,6 +23,10 @@ import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.Result; import io.r2dbc.spi.Statement; +import io.r2dbc.spi.test.MockColumnMetadata; +import io.r2dbc.spi.test.MockResult; +import io.r2dbc.spi.test.MockRow; +import io.r2dbc.spi.test.MockRowMetadata; import reactor.core.CoreSubscriber; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -38,7 +42,9 @@ import org.mockito.junit.MockitoJUnitRunner; import org.reactivestreams.Publisher; import org.reactivestreams.Subscription; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.annotation.Id; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.r2dbc.dialect.PostgresDialect; import org.springframework.data.r2dbc.mapping.SettableValue; import org.springframework.data.r2dbc.support.R2dbcExceptionTranslator; @@ -369,6 +375,127 @@ public class DefaultDatabaseClientUnitTests { .then()).withMessageContaining("UPDATE contains no assignments"); } + @Test // gh-260 + public void shouldProjectGenericExecuteAs() { + + Statement statement = mock(Statement.class); + when(connection.createStatement(anyString())).thenReturn(statement); + + MockRowMetadata metadata = MockRowMetadata.builder() + .columnMetadata(MockColumnMetadata.builder().name("name").build()).build(); + MockResult result = MockResult.builder().rowMetadata(metadata) + .row(MockRow.builder().identified(0, Object.class, "Walter").build()).build(); + + doReturn(Flux.just(result)).when(statement).execute(); + + DatabaseClient databaseClient = DatabaseClient.builder() // + .connectionFactory(connectionFactory) // + .projectionFactory(new SpelAwareProxyProjectionFactory()) // + .dataAccessStrategy(new DefaultReactiveDataAccessStrategy(PostgresDialect.INSTANCE)) // + .build(); + + databaseClient.execute("SELECT * FROM person") // + .as(Projection.class) // + .fetch() // + .one() // + .as(StepVerifier::create) // + .consumeNextWith(actual -> { + + assertThat(actual.getName()).isEqualTo("Walter"); + assertThat(actual.getGreeting()).isEqualTo("Hello Walter"); + + }) // + .verifyComplete(); + } + + @Test // gh-260 + public void shouldProjectGenericSelectAs() { + + Statement statement = mock(Statement.class); + when(connection.createStatement(anyString())).thenReturn(statement); + + MockRowMetadata metadata = MockRowMetadata.builder() + .columnMetadata(MockColumnMetadata.builder().name("name").build()).build(); + MockResult result = MockResult.builder().rowMetadata(metadata) + .row(MockRow.builder().identified(0, Object.class, "Walter").build()).build(); + + doReturn(Flux.just(result)).when(statement).execute(); + + DatabaseClient databaseClient = DatabaseClient.builder() // + .connectionFactory(connectionFactory) // + .projectionFactory(new SpelAwareProxyProjectionFactory()) // + .dataAccessStrategy(new DefaultReactiveDataAccessStrategy(PostgresDialect.INSTANCE)) // + .build(); + + databaseClient.select().from("person") // + .project("*") // + .as(Projection.class) // + .fetch() // + .one() // + .as(StepVerifier::create) // + .consumeNextWith(actual -> { + + assertThat(actual.getName()).isEqualTo("Walter"); + assertThat(actual.getGreeting()).isEqualTo("Hello Walter"); + + }) // + .verifyComplete(); + } + + @Test // gh-260 + public void shouldProjectTypedSelectAs() { + + Statement statement = mock(Statement.class); + when(connection.createStatement(anyString())).thenReturn(statement); + + MockRowMetadata metadata = MockRowMetadata.builder() + .columnMetadata(MockColumnMetadata.builder().name("name").build()).build(); + MockResult result = MockResult.builder().rowMetadata(metadata) + .row(MockRow.builder().identified("name", Object.class, "Walter").build()).build(); + + doReturn(Flux.just(result)).when(statement).execute(); + + DatabaseClient databaseClient = DatabaseClient.builder() // + .connectionFactory(connectionFactory) // + .projectionFactory(new SpelAwareProxyProjectionFactory()) // + .dataAccessStrategy(new DefaultReactiveDataAccessStrategy(PostgresDialect.INSTANCE)) // + .build(); + + databaseClient.select().from(Person.class) // + .as(Projection.class) // + .one() // + .as(StepVerifier::create) // + .consumeNextWith(actual -> { + + assertThat(actual.getName()).isEqualTo("Walter"); + assertThat(actual.getGreeting()).isEqualTo("Hello Walter"); + + }) // + .verifyComplete(); + + } + + static class Person { + + String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + interface Projection { + + String getName(); + + @Value("#{'Hello ' + target.name}") + String getGreeting(); + } + static class IdOnly { @Id String id;