#220 - Introduce R2dbcEntityTemplate.

We now provide a Template API that exposes entity-centric methods. It complements DatabaseClient's simple object mapper methods.

Original pull request: #287.
This commit is contained in:
Mark Paluch
2020-01-24 14:34:33 +01:00
parent 4edc759155
commit b5445b8c3f
7 changed files with 1450 additions and 0 deletions

View File

@@ -204,6 +204,15 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor {
return ConnectionFactoryUtils.getConnection(obtainConnectionFactory());
}
/**
* Obtain the {@link ReactiveDataAccessStrategy}.
*
* @return a the ReactiveDataAccessStrategy.
*/
protected ReactiveDataAccessStrategy getDataAccessStrategy() {
return dataAccessStrategy;
}
/**
* Release the {@link Connection}.
*

View File

@@ -0,0 +1,144 @@
/*
* Copyright 2020 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.core;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.TransientDataAccessResourceException;
import org.springframework.data.r2dbc.query.Query;
import org.springframework.data.r2dbc.query.Update;
/**
* Interface specifying a basic set of reactive R2DBC operations using entities. Implemented by
* {@link R2dbcEntityTemplate}. Not often used directly, but a useful option to enhance testability, as it can easily be
* mocked or stubbed.
*
* @author Mark Paluch
* @since 1.1
* @see DatabaseClient
*/
public interface R2dbcEntityOperations extends FluentR2dbcOperations {
/**
* Expose the underlying {@link DatabaseClient} to allow SQL operations.
*
* @return the underlying {@link DatabaseClient}.
* @see DatabaseClient
*/
DatabaseClient getDatabaseClient();
// -------------------------------------------------------------------------
// Methods dealing with org.springframework.data.r2dbc.query.Query
// -------------------------------------------------------------------------
/**
* Returns the number of rows for the given entity class applying {@link Query}. This overridden method allows users
* to further refine the selection Query using a {@link Query} predicate to determine how many entities of the given
* {@link Class type} match the Query.
*
* @param query user-defined count {@link Query} to execute; must not be {@literal null}.
* @param entityClass {@link Class type} of the entity; must not be {@literal null}.
* @return the number of existing entities.
* @throws DataAccessException if any problem occurs while executing the query.
*/
Mono<Long> count(Query query, Class<?> entityClass) throws DataAccessException;
/**
* Determine whether the result for {@code entityClass} {@link Query} yields at least one row.
*
* @param query user-defined exists {@link Query} to execute; must not be {@literal null}.
* @param entityClass {@link Class type} of the entity; must not be {@literal null}.
* @return {@literal true} if the object exists.
* @throws DataAccessException if any problem occurs while executing the query.
* @since 2.1
*/
Mono<Boolean> exists(Query query, Class<?> entityClass) throws DataAccessException;
/**
* Execute a {@code SELECT} query and convert the resulting items to a stream of entities.
*
* @param query must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @return the result objects returned by the action.
* @throws DataAccessException if there is any problem issuing the execution.
*/
<T> Flux<T> select(Query query, Class<T> entityClass) throws DataAccessException;
/**
* Execute a {@code SELECT} query and convert the resulting item to an entity.
*
* @param query must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @return the result object returned by the action or {@link Mono#empty()}.
* @throws DataAccessException if there is any problem issuing the execution.
*/
<T> Mono<T> selectOne(Query query, Class<T> entityClass) throws DataAccessException;
/**
* Update the queried entities and return {@literal true} if the update was applied.
*
* @param query must not be {@literal null}.
* @param update must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @return the number of affected rows.
* @throws DataAccessException if there is any problem executing the query.
*/
Mono<Integer> update(Query query, Update update, Class<?> entityClass) throws DataAccessException;
/**
* Remove entities (rows)/columns from the table by {@link Query}.
*
* @param query must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @return the number of affected rows.
* @throws DataAccessException if there is any problem issuing the execution.
*/
Mono<Integer> delete(Query query, Class<?> entityClass) throws DataAccessException;
// -------------------------------------------------------------------------
// Methods dealing with entities
// -------------------------------------------------------------------------
/**
* Insert the given entity and emit the entity if the insert was applied.
*
* @param entity The entity to insert, must not be {@literal null}.
* @return the inserted entity.
* @throws DataAccessException if there is any problem issuing the execution.
*/
<T> Mono<T> insert(T entity) throws DataAccessException;
/**
* Update the given entity and emit the entity if the update was applied.
*
* @param entity The entity to update, must not be {@literal null}.
* @return the updated entity.
* @throws DataAccessException if there is any problem issuing the execution.
* @throws TransientDataAccessResourceException if the update did not affect any rows.
*/
<T> Mono<T> update(T entity) throws DataAccessException;
/**
* Delete the given entity and emit the entity if the delete was applied.
*
* @param entity must not be {@literal null}.
* @return the deleted entity.
* @throws DataAccessException if there is any problem issuing the execution.
*/
<T> Mono<T> delete(T entity) throws DataAccessException;
}

View File

@@ -0,0 +1,491 @@
/*
* Copyright 2020 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.core;
import io.r2dbc.spi.Row;
import io.r2dbc.spi.RowMetadata;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.beans.FeatureDescriptor;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.TransientDataAccessResourceException;
import org.springframework.data.mapping.IdentifierAccessor;
import org.springframework.data.mapping.MappingException;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.projection.ProjectionInformation;
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
import org.springframework.data.r2dbc.mapping.R2dbcMappingContext;
import org.springframework.data.r2dbc.query.Criteria;
import org.springframework.data.r2dbc.query.Query;
import org.springframework.data.r2dbc.query.Update;
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
import org.springframework.data.relational.core.sql.Functions;
import org.springframework.data.util.ProxyUtils;
import org.springframework.util.Assert;
/**
* Implementation of {@link R2dbcEntityOperations}. It simplifies the use of Reactive R2DBC usage through entities and
* helps to avoid common errors. This class uses {@link DatabaseClient} to execute SQL queries or updates, initiating
* iteration over {@link io.r2dbc.spi.Result}.
* <p>
* Can be used within a service implementation via direct instantiation with a {@link DatabaseClient} reference, or get
* prepared in an application context and given to services as bean reference.
*
* @author Mark Paluch
* @since 1.1
*/
public class R2dbcEntityTemplate implements R2dbcEntityOperations, BeanFactoryAware {
private final DatabaseClient databaseClient;
private final ReactiveDataAccessStrategy dataAccessStrategy;
private final MappingContext<? extends RelationalPersistentEntity<?>, ? extends RelationalPersistentProperty> mappingContext;
private final SpelAwareProxyProjectionFactory projectionFactory;
/**
* Create a new {@link R2dbcEntityTemplate} given {@link DatabaseClient}.
*
* @param databaseClient
*/
public R2dbcEntityTemplate(DatabaseClient databaseClient) {
Assert.notNull(databaseClient, "DatabaseClient must not be null");
this.databaseClient = databaseClient;
this.dataAccessStrategy = getDataAccessStrategy(databaseClient);
this.mappingContext = getMappingContext(this.dataAccessStrategy);
this.projectionFactory = new SpelAwareProxyProjectionFactory();
}
/**
* Create a new {@link R2dbcEntityTemplate} given {@link DatabaseClient} and {@link ReactiveDataAccessStrategy}.
*
* @param databaseClient
*/
public R2dbcEntityTemplate(DatabaseClient databaseClient, ReactiveDataAccessStrategy strategy) {
Assert.notNull(databaseClient, "DatabaseClient must not be null");
Assert.notNull(strategy, "ReactiveDataAccessStrategy must not be null");
this.databaseClient = databaseClient;
this.dataAccessStrategy = strategy;
this.mappingContext = strategy.getConverter().getMappingContext();
this.projectionFactory = new SpelAwareProxyProjectionFactory();
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#getDatabaseClient()
*/
@Override
public DatabaseClient getDatabaseClient() {
return this.databaseClient;
}
/*
* (non-Javadoc)
* @see org.springframework.beans.factory.BeanFactoryAware#setBeanFactory(org.springframework.beans.factory.BeanFactory)
*/
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
this.projectionFactory.setBeanFactory(beanFactory);
}
// -------------------------------------------------------------------------
// Methods dealing with org.springframework.data.r2dbc.core.FluentR2dbcOperations
// -------------------------------------------------------------------------
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.ReactiveSelectOperation#select(java.lang.Class)
*/
@Override
public <T> ReactiveSelect<T> select(Class<T> domainType) {
return new ReactiveSelectOperationSupport(this).select(domainType);
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.ReactiveInsertOperation#insert(java.lang.Class)
*/
@Override
public <T> ReactiveInsert<T> insert(Class<T> domainType) {
return new ReactiveInsertOperationSupport(this).insert(domainType);
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.ReactiveUpdateOperation#update(java.lang.Class)
*/
@Override
public ReactiveUpdate update(Class<?> domainType) {
return new ReactiveUpdateOperationSupport(this).update(domainType);
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.ReactiveDeleteOperation#delete(java.lang.Class)
*/
@Override
public ReactiveDelete delete(Class<?> domainType) {
return new ReactiveDeleteOperationSupport(this).delete(domainType);
}
// -------------------------------------------------------------------------
// Methods dealing with org.springframework.data.r2dbc.query.Query
// -------------------------------------------------------------------------
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#count(org.springframework.data.r2dbc.query.Query, java.lang.Class)
*/
@Override
public Mono<Long> count(Query query, Class<?> entityClass) throws DataAccessException {
Assert.notNull(query, "Query must not be null");
Assert.notNull(entityClass, "entity class must not be null");
return doCount(query, entityClass, getTableName(entityClass));
}
Mono<Long> doCount(Query query, Class<?> entityClass, String tableName) {
RelationalPersistentEntity<?> entity = getRequiredEntity(entityClass);
StatementMapper statementMapper = dataAccessStrategy.getStatementMapper().forType(entityClass);
StatementMapper.SelectSpec selectSpec = statementMapper //
.createSelect(tableName) //
.doWithTable((table, spec) -> {
return spec.withProjection(Functions.count(table.column(entity.getRequiredIdProperty().getColumnName())));
});
Optional<Criteria> criteria = query.getCriteria();
if (criteria.isPresent()) {
selectSpec = criteria.map(selectSpec::withCriteria).orElse(selectSpec);
}
PreparedOperation<?> operation = statementMapper.getMappedObject(selectSpec);
return this.databaseClient.execute(operation) //
.map((r, md) -> r.get(0, Long.class)) //
.first() //
.defaultIfEmpty(0L);
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#exists(org.springframework.data.r2dbc.query.Query, java.lang.Class)
*/
@Override
public Mono<Boolean> exists(Query query, Class<?> entityClass) throws DataAccessException {
Assert.notNull(query, "Query must not be null");
Assert.notNull(entityClass, "entity class must not be null");
return doExists(query, entityClass, getTableName(entityClass));
}
Mono<Boolean> doExists(Query query, Class<?> entityClass, String tableName) {
RelationalPersistentEntity<?> entity = getRequiredEntity(entityClass);
StatementMapper statementMapper = dataAccessStrategy.getStatementMapper().forType(entityClass);
String columnName = entity.hasIdProperty() ? entity.getRequiredIdProperty().getColumnName() : "*";
StatementMapper.SelectSpec selectSpec = statementMapper //
.createSelect(tableName) //
.withProjection(columnName);
Optional<Criteria> criteria = query.getCriteria();
if (criteria.isPresent()) {
selectSpec = criteria.map(selectSpec::withCriteria).orElse(selectSpec);
}
PreparedOperation<?> operation = statementMapper.getMappedObject(selectSpec);
return this.databaseClient.execute(operation) //
.map((r, md) -> r) //
.first() //
.hasElement();
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#select(org.springframework.data.r2dbc.query.Query, java.lang.Class)
*/
@Override
public <T> Flux<T> select(Query query, Class<T> entityClass) throws DataAccessException {
Assert.notNull(query, "Query must not be null");
Assert.notNull(entityClass, "entity class must not be null");
return doSelect(query, entityClass, getTableName(entityClass), entityClass).all();
}
<T> RowsFetchSpec<T> doSelect(Query query, Class<?> entityClass, String tableName, Class<T> returnType) {
RelationalPersistentEntity<?> entity = getRequiredEntity(entityClass);
StatementMapper statementMapper = dataAccessStrategy.getStatementMapper().forType(entityClass);
StatementMapper.SelectSpec selectSpec = statementMapper //
.createSelect(tableName) //
.withProjection(getSelectProjection(query, returnType));
if (query.getLimit() > 0) {
selectSpec = selectSpec.limit(query.getLimit());
}
if (query.getOffset() > 0) {
selectSpec = selectSpec.offset(query.getOffset());
}
if (query.isSorted()) {
selectSpec = selectSpec.withSort(query.getSort());
}
Optional<Criteria> criteria = query.getCriteria();
if (criteria.isPresent()) {
selectSpec = criteria.map(selectSpec::withCriteria).orElse(selectSpec);
}
PreparedOperation<?> operation = statementMapper.getMappedObject(selectSpec);
BiFunction<Row, RowMetadata, T> rowMapper;
if (returnType.isInterface()) {
rowMapper = dataAccessStrategy.getRowMapper(entityClass)
.andThen(o -> projectionFactory.createProjection(returnType, o));
} else {
rowMapper = dataAccessStrategy.getRowMapper(returnType);
}
return this.databaseClient.execute(operation).map(rowMapper);
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#selectOne(org.springframework.data.r2dbc.query.Query, java.lang.Class)
*/
@Override
public <T> Mono<T> selectOne(Query query, Class<T> entityClass) throws DataAccessException {
return doSelect(query.limit(2), entityClass, getTableName(entityClass), entityClass).one();
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#update(org.springframework.data.r2dbc.query.Query, org.springframework.data.r2dbc.query.Update, java.lang.Class)
*/
@Override
public Mono<Integer> update(Query query, Update update, Class<?> entityClass) throws DataAccessException {
Assert.notNull(query, "Query must not be null");
Assert.notNull(update, "Update must not be null");
Assert.notNull(entityClass, "entity class must not be null");
return doUpdate(query, update, entityClass, getTableName(entityClass));
}
Mono<Integer> doUpdate(Query query, Update update, Class<?> entityClass, String tableName) {
StatementMapper statementMapper = dataAccessStrategy.getStatementMapper().forType(entityClass);
StatementMapper.UpdateSpec selectSpec = statementMapper //
.createUpdate(tableName, update);
Optional<Criteria> criteria = query.getCriteria();
if (criteria.isPresent()) {
selectSpec = criteria.map(selectSpec::withCriteria).orElse(selectSpec);
}
PreparedOperation<?> operation = statementMapper.getMappedObject(selectSpec);
return this.databaseClient.execute(operation).fetch().rowsUpdated();
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#delete(org.springframework.data.r2dbc.query.Query, java.lang.Class)
*/
@Override
public Mono<Integer> delete(Query query, Class<?> entityClass) throws DataAccessException {
Assert.notNull(query, "Query must not be null");
Assert.notNull(entityClass, "entity class must not be null");
return doDelete(query, entityClass, getTableName(entityClass));
}
Mono<Integer> doDelete(Query query, Class<?> entityClass, String tableName) {
StatementMapper statementMapper = dataAccessStrategy.getStatementMapper().forType(entityClass);
StatementMapper.DeleteSpec selectSpec = statementMapper //
.createDelete(tableName);
Optional<Criteria> criteria = query.getCriteria();
if (criteria.isPresent()) {
selectSpec = criteria.map(selectSpec::withCriteria).orElse(selectSpec);
}
PreparedOperation<?> operation = statementMapper.getMappedObject(selectSpec);
return this.databaseClient.execute(operation).fetch().rowsUpdated().defaultIfEmpty(0);
}
// -------------------------------------------------------------------------
// Methods dealing with entities
// -------------------------------------------------------------------------
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#insert(java.lang.Object)
*/
@Override
public <T> Mono<T> insert(T entity) throws DataAccessException {
Assert.notNull(entity, "Entity must not be null");
return doInsert(entity, getRequiredEntity(entity).getTableName());
}
<T> Mono<T> doInsert(T entity, String tableName) {
RelationalPersistentEntity<T> persistentEntity = getRequiredEntity(entity);
return this.databaseClient.insert() //
.into(persistentEntity.getType()) //
.table(tableName).using(entity) //
.map(this.dataAccessStrategy.getConverter().populateIdIfNecessary(entity)) //
.first() //
.defaultIfEmpty(entity);
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#update(java.lang.Object)
*/
@Override
public <T> Mono<T> update(T entity) throws DataAccessException {
Assert.notNull(entity, "Entity must not be null");
RelationalPersistentEntity<T> persistentEntity = getRequiredEntity(entity);
return this.databaseClient.update() //
.table(persistentEntity.getType()) //
.table(persistentEntity.getTableName()).using(entity) //
.fetch().rowsUpdated().handle((rowsUpdated, sink) -> {
if (rowsUpdated == 0) {
sink.error(new TransientDataAccessResourceException(
String.format("Failed to update table [%s]. Row with Id [%s] does not exist.",
persistentEntity.getTableName(), persistentEntity.getIdentifierAccessor(entity).getIdentifier())));
} else {
sink.next(entity);
}
});
}
/*
* (non-Javadoc)
* @see org.springframework.data.r2dbc.core.R2dbcEntityOperations#delete(java.lang.Object)
*/
@Override
public <T> Mono<T> delete(T entity) throws DataAccessException {
Assert.notNull(entity, "Entity must not be null");
RelationalPersistentEntity<?> persistentEntity = getRequiredEntity(entity);
return delete(getByIdQuery(entity, persistentEntity), persistentEntity.getType()).thenReturn(entity);
}
private <T> Query getByIdQuery(T entity, RelationalPersistentEntity<?> persistentEntity) {
if (!persistentEntity.hasIdProperty()) {
throw new MappingException("No id property found for object of type " + persistentEntity.getType() + "!");
}
IdentifierAccessor identifierAccessor = persistentEntity.getIdentifierAccessor(entity);
Object id = identifierAccessor.getRequiredIdentifier();
return Query.query(Criteria.where(persistentEntity.getRequiredIdProperty().getName()).is(id));
}
String getTableName(Class<?> entityClass) {
return getRequiredEntity(entityClass).getTableName();
}
private RelationalPersistentEntity<?> getRequiredEntity(Class<?> entityClass) {
return this.mappingContext.getRequiredPersistentEntity(entityClass);
}
private <T> RelationalPersistentEntity<T> getRequiredEntity(T entity) {
Class<?> entityType = ProxyUtils.getUserClass(entity);
return (RelationalPersistentEntity) getRequiredEntity(entityType);
}
private <T> List<String> getSelectProjection(Query query, Class<T> returnType) {
if (query.getColumns().isEmpty()) {
if (returnType.isInterface()) {
ProjectionInformation projectionInformation = projectionFactory.getProjectionInformation(returnType);
if (projectionInformation.isClosed()) {
return projectionInformation.getInputProperties().stream().map(FeatureDescriptor::getName)
.collect(Collectors.toList());
}
}
return Collections.singletonList("*");
}
return query.getColumns();
}
private static ReactiveDataAccessStrategy getDataAccessStrategy(DatabaseClient databaseClient) {
if (databaseClient instanceof DefaultDatabaseClient) {
DefaultDatabaseClient client = (DefaultDatabaseClient) databaseClient;
return client.getDataAccessStrategy();
}
throw new IllegalStateException("Cannot obtain ReactiveDataAccessStrategy");
}
private static MappingContext<? extends RelationalPersistentEntity<?>, ? extends RelationalPersistentProperty> getMappingContext(
ReactiveDataAccessStrategy strategy) {
if (strategy instanceof DefaultReactiveDataAccessStrategy) {
DefaultReactiveDataAccessStrategy strategy1 = (DefaultReactiveDataAccessStrategy) strategy;
return strategy1.getMappingContext();
}
return new R2dbcMappingContext();
}
}

View File

@@ -0,0 +1,234 @@
/*
* Copyright 2020 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.query;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Query object representing {@link Criteria}, columns, {@link Sort}, and limit/offset for a SQL query. {@link Query} is
* created with a fluent API creating immutable objects.
*
* @author Mark Paluch
* @since 1.1
* @see Criteria
* @see Sort
* @see Pageable
*/
public class Query {
private final @Nullable Criteria criteria;
// TODO: select list should be List<Expression>
private final List<String> columns;
private final Sort sort;
private final int limit;
private final long offset;
/**
* Static factory method to create a {@link Query} using the provided {@link Criteria}.
*
* @param criteria must not be {@literal null}.
* @return a new {@link Query} for the given {@link Criteria}.
*/
public static Query query(Criteria criteria) {
return new Query(criteria);
}
/**
* Creates a new {@link Query} using the given {@link Criteria}.
*
* @param criteria must not be {@literal null}.
*/
private Query(@Nullable Criteria criteria) {
this.criteria = criteria;
this.sort = Sort.unsorted();
this.columns = Collections.emptyList();
this.limit = -1;
this.offset = -1;
}
private Query(Criteria criteria, List<String> columns, Sort sort, int limit, long offset) {
this.criteria = criteria;
this.columns = columns;
this.sort = sort;
this.limit = limit;
this.offset = offset;
}
/**
* Create a new empty {@link Query}.
*
* @return
*/
public static Query empty() {
return new Query(null);
}
/**
* Add columns to the query.
*
* @param columns
* @return a new {@link Query} object containing the former settings with {@code columns} applied.
*/
public Query columns(String... columns) {
Assert.notNull(columns, "Columns must not be null");
return columns(Arrays.asList(columns));
}
/**
* Add columns to the query.
*
* @param columns
* @return a new {@link Query} object containing the former settings with {@code columns} applied.
*/
public Query columns(Collection<String> columns) {
Assert.notNull(columns, "Columns must not be null");
List<String> newColumns = new ArrayList<>(this.columns);
newColumns.addAll(columns);
return new Query(this.criteria, newColumns, this.sort, this.limit, offset);
}
/**
* Set number of rows to skip before returning results.
*
* @param offset
* @return a new {@link Query} object containing the former settings with {@code offset} applied.
*/
public Query offset(long offset) {
return new Query(this.criteria, this.columns, this.sort, this.limit, offset);
}
/**
* Limit the number of returned documents to {@code limit}.
*
* @param limit
* @return a new {@link Query} object containing the former settings with {@code limit} applied.
*/
public Query limit(int limit) {
return new Query(this.criteria, this.columns, this.sort, limit, this.offset);
}
/**
* Set the given pagination information on the {@link Query} instance. Will transparently set {@code offset} and
* {@code limit} as well as applying the {@link Sort} instance defined with the {@link Pageable}.
*
* @param pageable
* @return a new {@link Query} object containing the former settings with {@link Pageable} applied.
*/
public Query with(Pageable pageable) {
if (pageable.isUnpaged()) {
return this;
}
assertNoCaseSort(pageable.getSort());
return new Query(this.criteria, this.columns, this.sort.and(sort), pageable.getPageSize(), pageable.getOffset());
}
/**
* Add a {@link Sort} to the {@link Query} instance.
*
* @param sort
* @return a new {@link Query} object containing the former settings with {@link Sort} applied.
*/
public Query sort(Sort sort) {
Assert.notNull(sort, "Sort must not be null!");
if (sort.isUnsorted()) {
return this;
}
assertNoCaseSort(sort);
return new Query(this.criteria, this.columns, this.sort.and(sort), this.limit, this.offset);
}
/**
* Return the {@link Criteria} to be applied.
*
* @return
*/
public Optional<Criteria> getCriteria() {
return Optional.ofNullable(this.criteria);
}
/**
* Return the columns that this query should project.
*
* @return
*/
public List<String> getColumns() {
return columns;
}
/**
* Return {@literal true} if the {@link Query} has a sort parameter.
*
* @return {@literal true} if sorted.
* @see Sort#isSorted()
*/
public boolean isSorted() {
return sort.isSorted();
}
public Sort getSort() {
return sort;
}
/**
* Return the number of rows to skip.
*
* @return
*/
public long getOffset() {
return this.offset;
}
/**
* Return the maximum number of rows to be return.
*
* @return
*/
public int getLimit() {
return this.limit;
}
private static void assertNoCaseSort(Sort sort) {
for (Sort.Order order : sort) {
if (order.isIgnoreCase()) {
throw new IllegalArgumentException(String.format("Given sort contained an Order for %s with ignore case;"
+ " R2DBC does not support sorting ignoring case currently", order.getProperty()));
}
}
}
}

View File

@@ -22,6 +22,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.UnaryOperator;
import org.springframework.beans.DirectFieldAccessor;
import org.springframework.data.domain.Sort;
import org.springframework.data.mapping.PersistentPropertyPath;
import org.springframework.data.mapping.PropertyPath;
@@ -39,12 +40,14 @@ import org.springframework.data.r2dbc.query.Criteria.Combinator;
import org.springframework.data.r2dbc.query.Criteria.Comparator;
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
import org.springframework.data.relational.core.sql.Aliased;
import org.springframework.data.relational.core.sql.Column;
import org.springframework.data.relational.core.sql.Condition;
import org.springframework.data.relational.core.sql.Expression;
import org.springframework.data.relational.core.sql.IdentifierProcessing;
import org.springframework.data.relational.core.sql.SQL;
import org.springframework.data.relational.core.sql.SqlIdentifier;
import org.springframework.data.relational.core.sql.SimpleFunction;
import org.springframework.data.relational.core.sql.Table;
import org.springframework.data.util.ClassTypeInformation;
import org.springframework.data.util.TypeInformation;
@@ -119,6 +122,51 @@ public class QueryMapper {
return Sort.by(mappedOrder);
}
/**
* Map the {@link Expression} object to apply field name mapping using {@link Class the type to read}.
*
* @param expression must not be {@literal null}.
* @param entity related {@link RelationalPersistentEntity}, can be {@literal null}.
* @return the mapped {@link Expression}.
* @since 1.1
*/
public Expression getMappedObject(Expression expression, @Nullable RelationalPersistentEntity<?> entity) {
if (entity == null) {
return expression;
}
if (expression instanceof Column) {
Column column = (Column) expression;
Field field = createPropertyField(entity, column.getName());
return column instanceof Aliased
? Column.aliased(field.getMappedColumnName(), column.getTable(), ((Aliased) column).getAlias())
: Column.create(field.getMappedColumnName(), column.getTable());
}
if (expression instanceof SimpleFunction) {
// Revisit after https://jira.spring.io/browse/DATAJDBC-478
SimpleFunction function = (SimpleFunction) expression;
DirectFieldAccessor accessor = new DirectFieldAccessor(function);
List<Expression> arguments = (List<Expression>) accessor.getPropertyValue("expressions");
List<Expression> mappedArguments = new ArrayList<>(arguments.size());
for (Expression argument : arguments) {
mappedArguments.add(getMappedObject(argument, entity));
}
SimpleFunction mappedFunction = SimpleFunction.create(function.getFunctionName(), mappedArguments);
return function instanceof Aliased ? mappedFunction.as(((Aliased) function).getAlias()) : mappedFunction;
}
throw new IllegalArgumentException(String.format("Cannot map %s", expression));
}
/**
* Map a {@link Criteria} object into {@link Condition} and consider value/{@code NULL} {@link Bindings}.
*
@@ -287,6 +335,10 @@ public class QueryMapper {
}
}
Field createPropertyField(@Nullable RelationalPersistentEntity<?> entity, String key) {
return entity == null ? new Field(key) : new MetadataBackedField(key, entity, mappingContext);
}
Field createPropertyField(@Nullable RelationalPersistentEntity<?> entity, String key,
MappingContext<? extends RelationalPersistentEntity<?>, RelationalPersistentProperty> mappingContext) {
return entity == null ? new Field(key) : new MetadataBackedField(key, entity, mappingContext);

View File

@@ -0,0 +1,208 @@
/*
* Copyright 2020 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.core;
import static org.assertj.core.api.Assertions.*;
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.test.StepVerifier;
import java.util.Collections;
import org.junit.Before;
import org.junit.Test;
import org.springframework.data.annotation.Id;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.dialect.PostgresDialect;
import org.springframework.data.r2dbc.mapping.SettableValue;
import org.springframework.data.r2dbc.query.Criteria;
import org.springframework.data.r2dbc.query.Query;
import org.springframework.data.r2dbc.query.Update;
import org.springframework.data.r2dbc.testing.StatementRecorder;
import org.springframework.data.relational.core.mapping.Column;
/**
* Unit tests for {@link R2dbcEntityTemplate}.
*
* @author Mark Paluch
*/
public class R2dbcEntityTemplateUnitTests {
DatabaseClient client;
R2dbcEntityTemplate entityTemplate;
StatementRecorder recorder;
@Before
public void before() {
recorder = StatementRecorder.newInstance();
client = DatabaseClient.builder().connectionFactory(recorder)
.dataAccessStrategy(new DefaultReactiveDataAccessStrategy(PostgresDialect.INSTANCE)).build();
entityTemplate = new R2dbcEntityTemplate(client);
}
@Test // gh-220
public void shouldCountBy() {
MockRowMetadata metadata = MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("name").build()).build();
MockResult result = MockResult.builder().rowMetadata(metadata)
.row(MockRow.builder().identified(0, Long.class, 1L).build()).build();
recorder.addStubbing(s -> s.startsWith("SELECT"), result);
entityTemplate.count(Query.query(Criteria.where("name").is("Walter")), Person.class) //
.as(StepVerifier::create) //
.expectNext(1L) //
.verifyComplete();
StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("SELECT"));
assertThat(statement.getSql()).isEqualTo("SELECT COUNT(person.id) FROM person WHERE person.THE_NAME = $1");
assertThat(statement.getBindings()).hasSize(1).containsEntry(0, SettableValue.from("Walter"));
}
@Test // gh-220
public void shouldExistsByCriteria() {
MockRowMetadata metadata = MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("name").build()).build();
MockResult result = MockResult.builder().rowMetadata(metadata)
.row(MockRow.builder().identified(0, Long.class, 1L).build()).build();
recorder.addStubbing(s -> s.startsWith("SELECT"), result);
entityTemplate.exists(Query.query(Criteria.where("name").is("Walter")), Person.class) //
.as(StepVerifier::create) //
.expectNext(true) //
.verifyComplete();
StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("SELECT"));
assertThat(statement.getSql()).isEqualTo("SELECT person.id FROM person WHERE person.THE_NAME = $1");
assertThat(statement.getBindings()).hasSize(1).containsEntry(0, SettableValue.from("Walter"));
}
@Test // gh-220
public void shouldSelectByCriteria() {
recorder.addStubbing(s -> s.startsWith("SELECT"), Collections.emptyList());
entityTemplate.select(Query.query(Criteria.where("name").is("Walter")).sort(Sort.by("name")), Person.class) //
.as(StepVerifier::create) //
.verifyComplete();
StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("SELECT"));
assertThat(statement.getSql())
.isEqualTo("SELECT person.* FROM person WHERE person.THE_NAME = $1 ORDER BY THE_NAME ASC");
assertThat(statement.getBindings()).hasSize(1).containsEntry(0, SettableValue.from("Walter"));
}
@Test // gh-220
public void shouldSelectOne() {
recorder.addStubbing(s -> s.startsWith("SELECT"), Collections.emptyList());
entityTemplate.selectOne(Query.query(Criteria.where("name").is("Walter")).sort(Sort.by("name")), Person.class) //
.as(StepVerifier::create) //
.verifyComplete();
StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("SELECT"));
assertThat(statement.getSql())
.isEqualTo("SELECT person.* FROM person WHERE person.THE_NAME = $1 ORDER BY THE_NAME ASC LIMIT 2");
assertThat(statement.getBindings()).hasSize(1).containsEntry(0, SettableValue.from("Walter"));
}
@Test // gh-220
public void shouldUpdateByQuery() {
MockRowMetadata metadata = MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("name").build()).build();
MockResult result = MockResult.builder().rowMetadata(metadata).rowsUpdated(1).build();
recorder.addStubbing(s -> s.startsWith("UPDATE"), result);
entityTemplate
.update(Query.query(Criteria.where("name").is("Walter")), Update.update("name", "Heisenberg"), Person.class) //
.as(StepVerifier::create) //
.expectNext(1) //
.verifyComplete();
StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("UPDATE"));
assertThat(statement.getSql()).isEqualTo("UPDATE person SET THE_NAME = $1 WHERE person.THE_NAME = $2");
assertThat(statement.getBindings()).hasSize(2).containsEntry(0, SettableValue.from("Heisenberg")).containsEntry(1,
SettableValue.from("Walter"));
}
@Test // gh-220
public void shouldDeleteByQuery() {
MockRowMetadata metadata = MockRowMetadata.builder()
.columnMetadata(MockColumnMetadata.builder().name("name").build()).build();
MockResult result = MockResult.builder().rowMetadata(metadata).rowsUpdated(1).build();
recorder.addStubbing(s -> s.startsWith("DELETE"), result);
entityTemplate.delete(Query.query(Criteria.where("name").is("Walter")), Person.class) //
.as(StepVerifier::create) //
.expectNext(1) //
.verifyComplete();
StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("DELETE"));
assertThat(statement.getSql()).isEqualTo("DELETE FROM person WHERE person.THE_NAME = $1");
assertThat(statement.getBindings()).hasSize(1).containsEntry(0, SettableValue.from("Walter"));
}
@Test // gh-220
public void shouldDeleteEntity() {
Person person = new Person();
person.id = "Walter";
recorder.addStubbing(s -> s.startsWith("DELETE"), Collections.emptyList());
entityTemplate.delete(person) //
.as(StepVerifier::create) //
.expectNext(person).verifyComplete();
StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("DELETE"));
assertThat(statement.getSql()).isEqualTo("DELETE FROM person WHERE person.id = $1");
assertThat(statement.getBindings()).hasSize(1).containsEntry(0, SettableValue.from("Walter"));
}
static class Person {
@Id String id;
@Column("THE_NAME") String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}

View File

@@ -0,0 +1,312 @@
/*
* Copyright 2020 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.testing;
import io.r2dbc.spi.Batch;
import io.r2dbc.spi.Connection;
import io.r2dbc.spi.ConnectionFactory;
import io.r2dbc.spi.ConnectionFactoryMetadata;
import io.r2dbc.spi.ConnectionMetadata;
import io.r2dbc.spi.IsolationLevel;
import io.r2dbc.spi.Result;
import io.r2dbc.spi.Statement;
import io.r2dbc.spi.ValidationDepth;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import org.reactivestreams.Publisher;
import org.springframework.data.r2dbc.mapping.SettableValue;
/**
* Recorder utility for R2DBC {@link Statement}s. Allows stubbing and introspection.
*
* @author Mark Paluch
*/
public class StatementRecorder implements ConnectionFactory {
private final Map<Predicate<String>, Supplier<List<Result>>> stubbings = new LinkedHashMap<>();
private final List<RecordedStatement> createdStatements = new ArrayList<>();
private final List<RecordedStatement> executedStatements = new ArrayList<>();
private StatementRecorder() {}
/**
* Create a new {@link StatementRecorder}.
*
* @return
*/
public static StatementRecorder newInstance() {
return new StatementRecorder();
}
/**
* Create a new {@link StatementRecorder} accepting a {@link Consumer configurer}.
*
* @param configurer
* @return
*/
public static StatementRecorder newInstance(Consumer<StatementRecorder> configurer) {
StatementRecorder statementRecorder = new StatementRecorder();
configurer.accept(statementRecorder);
return statementRecorder;
}
/**
* Add a stubbing rule given the {@link Predicate SQL Predicate} and a {@link Result} that is emitted by the executed
* statement. Typical usage:
*
* <pre class="code">
* recorder.addStubbing(sql -> sql.startsWith("SELECT"), result);
* </pre>
*
* @param sqlPredicate
* @param result
*/
public void addStubbing(Predicate<String> sqlPredicate, Result result) {
this.stubbings.put(sqlPredicate, () -> Collections.singletonList(result));
}
/**
* Add a stubbing rule given the {@link Predicate SQL Predicate} and a list of {@link Result results} that are emitted
* by the executed statement. Typical usage:
*
* <pre class="code">
* recorder.addStubbing(sql -> sql.startsWith("SELECT"), results);
* </pre>
*
* @param sqlPredicate
* @param result
*/
public void addStubbing(Predicate<String> sqlPredicate, List<Result> results) {
this.stubbings.put(sqlPredicate, () -> results);
}
/**
* Retrieve a statement by {@code sql}.
*
* @param sql
* @return
*/
public RecordedStatement getCreatedStatement(String sql) {
return getCreatedStatement(it -> compareSql(sql, it));
}
private static boolean compareSql(String pattern, String actual) {
return actual.equals(pattern) || Pattern.compile(pattern).matcher(actual).find();
}
/**
* Retrieve a statement by a {@link Predicate SQL predicate}.
*
* @param sql
* @return
*/
public RecordedStatement getCreatedStatement(Predicate<String> predicate) {
return createdStatements.stream().filter(recordedStatement -> {
return predicate.test(recordedStatement.getSql()) || predicate.test(recordedStatement.getSql().toLowerCase())
|| predicate.test(recordedStatement.getSql().toUpperCase());
}).findFirst().orElseThrow(() -> new NoSuchElementException("No statement found"));
}
public List<RecordedStatement> getCreatedStatements() {
return createdStatements;
}
public List<RecordedStatement> getExecutedStatements() {
return executedStatements;
}
@Override
public Publisher<? extends Connection> create() {
return Mono.just(new RecorderConnection());
}
@Override
public ConnectionFactoryMetadata getMetadata() {
return () -> "StatementRecorder";
}
class RecorderConnection implements Connection {
@Override
public Publisher<Void> beginTransaction() {
return createStatement("BEGIN").execute().then();
}
@Override
public Publisher<Void> close() {
return createStatement("CLOSE").execute().then();
}
@Override
public Publisher<Void> commitTransaction() {
return createStatement("COMMIT").execute().then();
}
@Override
public Batch createBatch() {
throw new UnsupportedOperationException("createBatch not yet supported");
}
@Override
public Publisher<Void> createSavepoint(String name) {
return createStatement("CREATE SAVEPOINT " + name).execute().then();
}
@Override
public RecordedStatement createStatement(String sql) {
RecordedStatement statement = doCreateStatement(sql);
createdStatements.add(statement);
return statement;
}
private RecordedStatement doCreateStatement(String sql) {
for (Map.Entry<Predicate<String>, Supplier<List<Result>>> entry : stubbings.entrySet()) {
if (entry.getKey().test(sql) || entry.getKey().test(sql.toLowerCase())
|| entry.getKey().test(sql.toUpperCase())) {
return new RecordedStatement(sql, entry.getValue().get());
}
}
return new RecordedStatement(sql, Collections.emptyList());
}
@Override
public boolean isAutoCommit() {
throw new UnsupportedOperationException("isAutoCommit not yet supported");
}
@Override
public ConnectionMetadata getMetadata() {
throw new UnsupportedOperationException("getMetadata not yet supported");
}
@Override
public IsolationLevel getTransactionIsolationLevel() {
throw new UnsupportedOperationException("getTransactionIsolationLevel not yet supported");
}
@Override
public Publisher<Void> releaseSavepoint(String name) {
return createStatement("RELEASE SAVEPOINT " + name).execute().then();
}
@Override
public Publisher<Void> rollbackTransaction() {
return createStatement("ROLLBACK").execute().then();
}
@Override
public Publisher<Void> rollbackTransactionToSavepoint(String name) {
return createStatement("ROLLBACK TO " + name).execute().then();
}
@Override
public Publisher<Void> setAutoCommit(boolean autoCommit) {
return createStatement("SET AUTOCOMMIT " + autoCommit).execute().then();
}
@Override
public Publisher<Void> setTransactionIsolationLevel(IsolationLevel isolationLevel) {
return createStatement("SET TRANSACTION ISOLATION LEVEL " + isolationLevel.asSql()).execute().then();
}
@Override
public Publisher<Boolean> validate(ValidationDepth depth) {
return Mono.just(true);
}
}
public class RecordedStatement implements Statement {
private final String sql;
private final List<Result> results;
private final Map<Object, SettableValue> bindings = new LinkedHashMap<>();
public RecordedStatement(String sql, Result result) {
this(sql, Collections.singletonList(result));
}
public RecordedStatement(String sql, List<Result> results) {
this.sql = sql;
this.results = results;
}
public Map<Object, SettableValue> getBindings() {
return bindings;
}
public String getSql() {
return sql;
}
@Override
public Statement add() {
return this;
}
@Override
public Statement bind(int index, Object o) {
this.bindings.put(index, SettableValue.from(o));
return this;
}
@Override
public Statement bind(String identifier, Object o) {
this.bindings.put(identifier, SettableValue.from(o));
return this;
}
@Override
public Statement bindNull(int index, Class<?> type) {
this.bindings.put(index, SettableValue.empty(type));
return this;
}
@Override
public Statement bindNull(String identifier, Class<?> type) {
this.bindings.put(identifier, SettableValue.empty(type));
return this;
}
@Override
public Flux<Result> execute() {
return Flux.fromIterable(results).doOnSubscribe(subscription -> executedStatements.add(this));
}
}
}