#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:
Mark Paluch
2020-01-22 15:49:38 +01:00
parent d6189856a9
commit 851cbafa5f
6 changed files with 224 additions and 16 deletions

View File

@@ -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:

View File

@@ -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();
}

View File

@@ -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.
*

View File

@@ -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.

View File

@@ -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));
}
/*

View File

@@ -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;