#260 - Support interface projections with DatabaseClient.as(…).
We now support interface projections when using as(Class) through ProjectionFactory. Simple, type-less queries (execute, select from table) are backed by Map implementations and require the projection type to return a similar type than the expected value. Simple types (such as numeric types) are converted between the backing result and the projection. Conversion of complex types requires a source with type information such as a typed select (select().from(Person.class).as(PersonProjection.class)) to apply registered converters on property-level.
This commit is contained in:
@@ -40,14 +40,18 @@ Mono<Person> first = databaseClient.select()
|
||||
<1> Selecting from a table by name returns row results as `Map<String, Object>` 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 <<projections,Projections>> 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<T>)`) by using Spring Data's mapping-metadata.
|
||||
* As `Map<String, Object>` where column names are mapped to their value. Column names are looked up in a case-insensitive way.
|
||||
* As `Map<String, Object>` 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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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<T> extends ExecuteSpecSupport implements TypedExecuteSpec<T> {
|
||||
|
||||
private final Class<T> typeToRead;
|
||||
private final @Nullable Class<T> typeToRead;
|
||||
private final BiFunction<Row, RowMetadata, T> mappingFunction;
|
||||
|
||||
DefaultTypedExecuteSpec(Map<Integer, SettableValue> byIndex, Map<String, SettableValue> 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<Integer, SettableValue> byIndex, Map<String, SettableValue> byName,
|
||||
@@ -638,6 +649,9 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor {
|
||||
|
||||
@Override
|
||||
public <T> TypedSelectSpec<T> from(Class<T> 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<Row, RowMetadata, R> 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<T> extends DefaultSelectSpecSupport implements TypedSelectSpec<T> {
|
||||
|
||||
private final @Nullable Class<T> typeToRead;
|
||||
private final Class<T> typeToRead;
|
||||
private final BiFunction<Row, RowMetadata, T> mappingFunction;
|
||||
|
||||
DefaultTypedSelectSpec(@Nullable Class<T> typeToRead) {
|
||||
DefaultTypedSelectSpec(Class<T> 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<Row, RowMetadata, R> 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 <T> TypedInsertSpec<T> into(Class<T> table) {
|
||||
|
||||
assertRegularClass(table);
|
||||
|
||||
return new DefaultTypedInsertSpec<>(table, ColumnMapRowMapper.INSTANCE);
|
||||
}
|
||||
}
|
||||
@@ -1136,6 +1170,9 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor {
|
||||
|
||||
@Override
|
||||
public <T> TypedUpdateSpec<T> table(Class<T> table) {
|
||||
|
||||
assertRegularClass(table);
|
||||
|
||||
return new DefaultTypedUpdateSpec<>(table, null, null);
|
||||
}
|
||||
}
|
||||
@@ -1297,6 +1334,9 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor {
|
||||
|
||||
@Override
|
||||
public <T> DefaultDeleteSpec<T> from(Class<T> 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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user